From 99f661538d073b33f577a3e4868d6ea176e4a5de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 00:27:06 -1000 Subject: [PATCH 001/169] 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 002/169] 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 003/169] 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 004/169] 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 005/169] 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 006/169] 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 007/169] 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 008/169] 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 009/169] 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 010/169] 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 011/169] 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 012/169] 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 013/169] 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 014/169] 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 015/169] 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 016/169] 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 017/169] 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 018/169] 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 019/169] 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 020/169] 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 021/169] 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 022/169] 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 023/169] 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 024/169] 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 025/169] 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 026/169] 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 027/169] 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 028/169] 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 029/169] 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 030/169] 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 031/169] 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 032/169] 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 033/169] 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 034/169] 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 035/169] 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 036/169] 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 037/169] 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 038/169] 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 039/169] 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 040/169] 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 041/169] 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 042/169] 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 043/169] 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 044/169] 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 045/169] 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 046/169] 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 047/169] 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 048/169] 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 049/169] 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 050/169] 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 051/169] 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 052/169] 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 053/169] 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 054/169] 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 055/169] 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 056/169] 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 057/169] 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 058/169] 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 059/169] 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 060/169] 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 061/169] 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 062/169] 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 063/169] 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 064/169] 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 065/169] 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 066/169] 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 067/169] 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 068/169] 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 069/169] 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 070/169] 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 071/169] 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 072/169] 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 073/169] 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 074/169] 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 075/169] 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 076/169] 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 077/169] 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 078/169] 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 079/169] 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 080/169] 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 081/169] 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 082/169] 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 083/169] 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 084/169] 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 085/169] 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 086/169] 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 087/169] 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 088/169] 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 089/169] 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 090/169] 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 091/169] 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 092/169] 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 093/169] 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 094/169] 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 095/169] 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 096/169] 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 097/169] 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 098/169] 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 099/169] 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 100/169] 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 101/169] =?UTF-8?q?Fix=20duplicate=20descriptions=20of=20`?= =?UTF-8?q?homematicip=5Fcloud.activate=5Feco=5Fmode=5Fwith=5F=E2=80=A6`?= =?UTF-8?q?=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 102/169] 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 103/169] 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 104/169] 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 105/169] 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 106/169] 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 107/169] 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 108/169] 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 109/169] 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 110/169] 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 111/169] 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 112/169] 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 113/169] 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 114/169] 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 115/169] 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 116/169] 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 117/169] 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 118/169] 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 119/169] 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 120/169] 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 121/169] 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 122/169] 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 123/169] 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 124/169] 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 125/169] 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 126/169] 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 127/169] 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 128/169] 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 129/169] 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 130/169] 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 131/169] 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 132/169] 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 133/169] 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 134/169] 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 135/169] 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 136/169] 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 137/169] 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 138/169] 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 139/169] 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 140/169] 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 141/169] 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 142/169] 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 143/169] 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 144/169] 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 145/169] 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 146/169] 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 147/169] 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 148/169] 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 149/169] 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 150/169] 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 151/169] 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 152/169] 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 153/169] 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 154/169] 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 155/169] 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 156/169] 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 157/169] 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 158/169] 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 159/169] 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 160/169] 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 161/169] 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 162/169] 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 163/169] 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 164/169] 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 165/169] 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 166/169] 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 167/169] 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 168/169] 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 169/169] 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 == []