From 0e4f44b775d97e370dc74e3fe0aeb84c06cc1613 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Apr 2025 16:51:32 +0200 Subject: [PATCH 001/116] Bump pySmartThings to 3.0.4 (#142739) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index dda7ef53cf5..4cd27e49664 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.0.2"] + "requirements": ["pysmartthings==3.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index df4c7b72cef..4a521e11914 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2322,7 +2322,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.2 +pysmartthings==3.0.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5d97d0a86c..d25277a0dde 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,7 +1895,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==3.0.2 +pysmartthings==3.0.4 # homeassistant.components.smarty pysmarty2==0.10.2 From 5816a24577139c6224e1992a5ca96ab450731f81 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 11 Apr 2025 17:21:12 +0200 Subject: [PATCH 002/116] Cleanup snapshot call in tests (#142750) --- tests/components/comelit/test_climate.py | 2 +- tests/components/comelit/test_cover.py | 2 +- tests/components/comelit/test_light.py | 2 +- tests/components/comelit/test_sensor.py | 2 +- tests/components/comelit/test_switch.py | 2 +- tests/components/voip/test_voip.py | 4 ++-- tests/components/wyoming/test_conversation.py | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/components/comelit/test_climate.py b/tests/components/comelit/test_climate.py index f9f28b4d675..059d7d27d77 100644 --- a/tests/components/comelit/test_climate.py +++ b/tests/components/comelit/test_climate.py @@ -42,7 +42,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_serial_bridge_config_entry.entry_id, ) diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py index 1d6c1435a5a..7fb74911cc6 100644 --- a/tests/components/comelit/test_cover.py +++ b/tests/components/comelit/test_cover.py @@ -42,7 +42,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_serial_bridge_config_entry.entry_id, ) diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py index 6c6de58c8ed..7c3cd15c135 100644 --- a/tests/components/comelit/test_light.py +++ b/tests/components/comelit/test_light.py @@ -36,7 +36,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_serial_bridge_config_entry.entry_id, ) diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py index 8473158f662..2b857f9c94a 100644 --- a/tests/components/comelit/test_sensor.py +++ b/tests/components/comelit/test_sensor.py @@ -33,7 +33,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_vedo_config_entry.entry_id, ) diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py index fb9a4aab79a..01efabf6b6f 100644 --- a/tests/components/comelit/test_switch.py +++ b/tests/components/comelit/test_switch.py @@ -36,7 +36,7 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, - snapshot(), + snapshot, mock_serial_bridge_config_entry.entry_id, ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 459ab020336..7ac76227a1b 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -126,7 +126,7 @@ async def test_calls_not_allowed( await done.wait() assert sum(played_audio_bytes) > 0 - assert played_audio_bytes == snapshot() + assert played_audio_bytes == snapshot async def test_pipeline_not_found( @@ -846,7 +846,7 @@ async def test_pipeline_error( await done.wait() assert sum(played_audio_bytes) > 0 - assert played_audio_bytes == snapshot() + assert played_audio_bytes == snapshot @pytest.mark.usefixtures("socket_enabled") diff --git a/tests/components/wyoming/test_conversation.py b/tests/components/wyoming/test_conversation.py index 02b04503962..7278a254d4a 100644 --- a/tests/components/wyoming/test_conversation.py +++ b/tests/components/wyoming/test_conversation.py @@ -192,7 +192,7 @@ async def test_connection_lost( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN assert result.response.speech, "No speech" - assert result.response.speech.get("plain", {}).get("speech") == snapshot() + assert result.response.speech.get("plain", {}).get("speech") == snapshot async def test_oserror( @@ -221,4 +221,4 @@ async def test_oserror( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN assert result.response.speech, "No speech" - assert result.response.speech.get("plain", {}).get("speech") == snapshot() + assert result.response.speech.get("plain", {}).get("speech") == snapshot From 7b78f6db1711f41f825eb3101c6f7175fb495e52 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Apr 2025 17:24:39 +0200 Subject: [PATCH 003/116] Fix SmartThings gas meter (#142741) --- .../components/smartthings/sensor.py | 6 +- tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/gas_meter.json | 61 ++++++ .../fixtures/devices/gas_meter.json | 56 +++++ .../smartthings/snapshots/test_init.ambr | 33 +++ .../smartthings/snapshots/test_sensor.ambr | 202 ++++++++++++++++++ 6 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/gas_meter.json create mode 100644 tests/components/smartthings/fixtures/devices/gas_meter.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 346516be480..e081f35d0e0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -413,7 +413,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.GAS_METER: { Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( @@ -421,7 +420,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="gas_meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ) ], Attribute.GAS_METER_CALORIFIC: [ @@ -443,7 +442,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.GAS_METER_VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ) ], }, @@ -1003,6 +1002,7 @@ CAPABILITY_TO_SENSORS: dict[ UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, + "ccf": UnitOfVolume.CENTUM_CUBIC_FEET, "lux": LIGHT_LUX, "mG": None, "μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 277c327744f..26af812fe1f 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -146,6 +146,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ikea_kadrilj", "aux_ac", "hw_q80r_soundbar", + "gas_meter", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/gas_meter.json b/tests/components/smartthings/fixtures/device_status/gas_meter.json new file mode 100644 index 00000000000..dc7f9b2e0c3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/gas_meter.json @@ -0,0 +1,61 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-27T14:06:11.704Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-04-11T13:00:00.444Z" + } + }, + "refresh": {}, + "gasMeter": { + "gasMeterPrecision": { + "value": { + "volume": 5, + "calorific": 1, + "conversion": 1 + }, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterCalorific": { + "value": 40, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterTime": { + "value": "2025-04-11T13:30:00.028Z", + "timestamp": "2025-04-11T13:30:00.532Z" + }, + "gasMeterVolume": { + "value": 14, + "unit": "ccf", + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeterConversion": { + "value": 3.6, + "timestamp": "2025-04-11T13:00:00.444Z" + }, + "gasMeter": { + "value": 450.5, + "unit": "kWh", + "timestamp": "2025-04-11T13:00:00.444Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/gas_meter.json b/tests/components/smartthings/fixtures/devices/gas_meter.json new file mode 100644 index 00000000000..9bf8af654c7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/gas_meter.json @@ -0,0 +1,56 @@ +{ + "items": [ + { + "deviceId": "3b57dca3-9a90-4f27-ba80-f947b1e60d58", + "name": "copper_gas_meter_v04", + "label": "Gas Meter", + "manufacturerName": "0A6v", + "presentationId": "ST_176e9efa-01d2-4d1b-8130-d37a4ef1b413", + "deviceManufacturerCode": "CopperLabs", + "locationId": "4e88bf74-3bed-4e6d-9fa7-6acb776a4df9", + "ownerId": "6fc21de5-123e-2f8c-2cc6-311635aeaaef", + "roomId": "fafae9db-a2b5-480f-8ff5-df8f913356df", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "gasMeter", + "version": 1 + } + ], + "categories": [ + { + "name": "GasMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-27T14:06:11.522Z", + "profile": { + "id": "5cca2553-23d6-43c4-81ad-a1c6c43efa00" + }, + "viper": { + "manufacturerName": "CopperLabs", + "modelName": "Virtual Gas Meter", + "endpointAppId": "viper_1d5767a0-af08-11ed-a999-9f1f172a27ff" + }, + "type": "VIPER", + "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 8ec97af7d84..db8c3a6ccc5 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1058,6 +1058,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[gas_meter] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3b57dca3-9a90-4f27-ba80-f947b1e60d58', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'CopperLabs', + 'model': 'Virtual Gas Meter', + 'model_id': None, + 'name': 'Gas Meter', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8ace345be18..e9441f2e408 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -8007,6 +8007,208 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas-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.gas_meter_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterVolume_gasMeterVolume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas Meter Gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeter_gasMeter', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Gas Meter Gas meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '450.5', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_calorific-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.gas_meter_gas_meter_calorific', + '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': 'Gas meter calorific', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter_calorific', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterCalorific_gasMeterCalorific', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_calorific-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas Meter Gas meter calorific', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter_calorific', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_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.gas_meter_gas_meter_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': 'Gas meter time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gas_meter_time', + 'unique_id': '3b57dca3-9a90-4f27-ba80-f947b1e60d58_main_gasMeter_gasMeterTime_gasMeterTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[gas_meter][sensor.gas_meter_gas_meter_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Gas Meter Gas meter time', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas_meter_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-04-11T13:30:00+00:00', + }) +# --- # name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 20a3a061a15236b0d146614d5b49092d7a5cfc34 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 11 Apr 2025 17:25:26 +0200 Subject: [PATCH 004/116] Update frontend to 20250411.0 (#142736) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 140d90c5dbe..64b49588ba1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250404.0"] + "requirements": ["home-assistant-frontend==20250411.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd51beebd41..96efb888ab7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250404.0 +home-assistant-frontend==20250411.0 home-assistant-intents==2025.3.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4a521e11914..02cee7082bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250404.0 +home-assistant-frontend==20250411.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d25277a0dde..fdbd0391601 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -987,7 +987,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250404.0 +home-assistant-frontend==20250411.0 # homeassistant.components.conversation home-assistant-intents==2025.3.28 From 5a1a41beb11edb06173749e65ba8594ebeb70ad5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:26:58 +0200 Subject: [PATCH 005/116] Fix reload of AVM FRITZ!Tools when new connected device is detected (#142430) --- homeassistant/components/fritz/button.py | 12 +----------- homeassistant/components/fritz/coordinator.py | 12 +++++++++++- homeassistant/components/fritz/entity.py | 3 +++ homeassistant/components/fritz/switch.py | 10 ---------- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 4a5f7e5a443..926e233d159 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -18,7 +18,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles +from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles from .coordinator import ( FRITZ_DATA_KEY, AvmWrapper, @@ -178,16 +178,6 @@ class FritzBoxWOLButton(FritzDeviceBase, ButtonEntity): self._name = f"{self.hostname} Wake on LAN" self._attr_unique_id = f"{self._mac}_wake_on_lan" self._is_available = True - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._mac)}, - default_manufacturer="AVM", - default_model="FRITZ!Box Tracked device", - default_name=device.hostname, - via_device=( - DOMAIN, - avm_wrapper.unique_id, - ), - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index d60232ec8ad..c0121ed9aa1 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -526,7 +526,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): def manage_device_info( self, dev_info: Device, dev_mac: str, consider_home: bool ) -> bool: - """Update device lists.""" + """Update device lists and return if device is new.""" _LOGGER.debug("Client dev_info: %s", dev_info) if dev_mac in self._devices: @@ -536,6 +536,16 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): device = FritzDevice(dev_mac, dev_info.name) device.update(dev_info, consider_home) self._devices[dev_mac] = device + + # manually register device entry for new connected device + dr.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(CONNECTION_NETWORK_MAC, dev_mac)}, + default_manufacturer="AVM", + default_model="FRITZ!Box Tracked device", + default_name=device.hostname, + via_device=(DOMAIN, self.unique_id), + ) return True async def async_send_signal_device_update(self, new_device: bool) -> None: diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index 33eb60d72cf..e8b5c49fd43 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -26,6 +26,9 @@ class FritzDeviceBase(CoordinatorEntity[AvmWrapper]): self._avm_wrapper = avm_wrapper self._mac: str = device.mac_address self._name: str = device.hostname or DEFAULT_DEVICE_NAME + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)} + ) @property def name(self) -> str: diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index c00849c5240..a033e45fcec 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -514,16 +514,6 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): self._name = f"{device.hostname} Internet Access" self._attr_unique_id = f"{self._mac}_internet_access" self._attr_entity_category = EntityCategory.CONFIG - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._mac)}, - default_manufacturer="AVM", - default_model="FRITZ!Box Tracked device", - default_name=device.hostname, - via_device=( - DOMAIN, - avm_wrapper.unique_id, - ), - ) @property def is_on(self) -> bool | None: From ca07975eadf15111b97bf65b87c6ee31b5e49064 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 11 Apr 2025 08:30:12 -0700 Subject: [PATCH 006/116] Fix Anthropic bug parsing a streaming response with no json (#142745) --- .../components/anthropic/conversation.py | 2 +- .../components/anthropic/test_conversation.py | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 5e5ad464eaa..288ec63509e 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -266,7 +266,7 @@ async def _transform_stream( raise ValueError("Unexpected stop event without a current block") if current_block["type"] == "tool_use": tool_block = cast(ToolUseBlockParam, current_block) - tool_args = json.loads(current_tool_args) + tool_args = json.loads(current_tool_args) if current_tool_args else {} tool_block["input"] = tool_args yield { "tool_calls": [ diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 67a4434a664..caaef43e931 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -303,11 +303,27 @@ async def test_conversation_agent( @patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@pytest.mark.parametrize( + ("tool_call_json_parts", "expected_call_tool_args"), + [ + ( + ['{"param1": "test_value"}'], + {"param1": "test_value"}, + ), + ( + ['{"para', 'm1": "test_valu', 'e"}'], + {"param1": "test_value"}, + ), + ([""], {}), + ], +) async def test_function_call( mock_get_tools, hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + tool_call_json_parts: list[str], + expected_call_tool_args: dict[str, Any], ) -> None: """Test function call from the assistant.""" agent_id = "conversation.claude" @@ -343,7 +359,7 @@ async def test_function_call( 1, "toolu_0123456789AbCdEfGhIjKlM", "test_tool", - ['{"para', 'm1": "test_valu', 'e"}'], + tool_call_json_parts, ), ] ) @@ -387,7 +403,7 @@ async def test_function_call( llm.ToolInput( id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", - tool_args={"param1": "test_value"}, + tool_args=expected_call_tool_args, ), llm.LLMContext( platform="anthropic", From a4fac730d4a667e0af9a3c5e2c5d3a407ebd2675 Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Fri, 11 Apr 2025 12:07:27 -0400 Subject: [PATCH 007/116] Upgrade sharkiq depedency to 1.1.0 (#142746) --- homeassistant/components/sharkiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 0e07dd96902..9f9009693e5 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==1.0.2"] + "requirements": ["sharkiq==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 02cee7082bc..fb8acbf1527 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2733,7 +2733,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.11 # homeassistant.components.sharkiq -sharkiq==1.0.2 +sharkiq==1.1.0 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdbd0391601..cc6b5bc0e8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2213,7 +2213,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.11 # homeassistant.components.sharkiq -sharkiq==1.0.2 +sharkiq==1.1.0 # homeassistant.components.simplefin simplefin4py==0.0.18 From a3341c4330241404213cbb2747104bd00df64403 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 11 Apr 2025 18:23:03 +0200 Subject: [PATCH 008/116] Add full test coverage for Comelit humidifier platform (#141852) * Add full test coverage for Comelit humidifier platform * clean * update snapshot * apply review comment --- tests/components/comelit/const.py | 2 +- .../comelit/snapshots/test_diagnostics.ambr | 4 +- .../comelit/snapshots/test_humidifier.ambr | 133 ++++++++ tests/components/comelit/test_humidifier.py | 292 ++++++++++++++++++ 4 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 tests/components/comelit/snapshots/test_humidifier.ambr create mode 100644 tests/components/comelit/test_humidifier.py diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index d06e6cfd8cb..0cbdaf56bbe 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -38,7 +38,7 @@ BRIDGE_DEVICE_QUERY = { type="climate", val=[ [221, 0, "U", "M", 50, 0, 0, "U"], - [650, 0, "O", "M", 500, 0, 0, "N"], + [650, 0, "U", "M", 500, 0, 0, "U"], [0, 0], ], protected=0, diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 51ea646df9f..c9ebf635353 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -27,12 +27,12 @@ list([ 650, 0, - 'O', + 'U', 'M', 500, 0, 0, - 'N', + 'U', ]), list([ 0, diff --git a/tests/components/comelit/snapshots/test_humidifier.ambr b/tests/components/comelit/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000..ffe53d09c5d --- /dev/null +++ b/tests/components/comelit/snapshots/test_humidifier.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_all_entities[humidifier.climate0_dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 90, + 'min_humidity': 10, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.climate0_dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dehumidifier', + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'dehumidifier', + 'unique_id': 'serial_bridge_config_entry_id-0-dehumidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[humidifier.climate0_dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 65.0, + 'device_class': 'dehumidifier', + 'friendly_name': 'Climate0 Dehumidifier', + 'humidity': 50.0, + 'max_humidity': 90, + 'min_humidity': 10, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.climate0_dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[humidifier.climate0_humidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 90, + 'min_humidity': 10, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.climate0_humidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier', + 'platform': 'comelit', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'humidifier', + 'unique_id': 'serial_bridge_config_entry_id-0-humidifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[humidifier.climate0_humidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'action': , + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 65.0, + 'device_class': 'humidifier', + 'friendly_name': 'Climate0 Humidifier', + 'humidity': 50.0, + 'max_humidity': 90, + 'min_humidity': 10, + 'mode': 'normal', + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.climate0_humidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/comelit/test_humidifier.py b/tests/components/comelit/test_humidifier.py new file mode 100644 index 00000000000..448453aadef --- /dev/null +++ b/tests/components/comelit/test_humidifier.py @@ -0,0 +1,292 @@ +"""Tests for Comelit SimpleHome humidifier platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, WATT +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.comelit.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "humidifier.climate0_humidifier" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.comelit.BRIDGE_PLATFORMS", [Platform.HUMIDIFIER] + ): + await setup_integration(hass, mock_serial_bridge_config_entry) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_serial_bridge_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + ("val", "mode", "humidity"), + [ + ( + [ + [100, 0, "U", "M", 210, 0, 0, "U"], + [650, 0, "U", "M", 500, 0, 0, "U"], + [0, 0], + ], + STATE_ON, + 50.0, + ), + ( + [ + [100, 1, "U", "A", 210, 1, 0, "O"], + [650, 1, "U", "A", 500, 1, 0, "O"], + [0, 0], + ], + STATE_ON, + 50.0, + ), + ( + [ + [100, 0, "O", "A", 210, 0, 0, "O"], + [650, 0, "O", "A", 500, 0, 0, "O"], + [0, 0], + ], + STATE_OFF, + 50.0, + ), + ], +) +async def test_humidifier_data_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + val: list[Any, Any], + mode: str, + humidity: float, +) -> None: + """Test humidifier data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val=val, + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == mode + assert state.attributes[ATTR_HUMIDITY] == humidity + + +async def test_humidifier_data_update_bad_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier data update.""" + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + mock_serial_bridge.get_all_devices.return_value[CLIMATE] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Climate0", + status=0, + human_status="off", + type="climate", + val="bad_data", + protected=0, + zone="Living room", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + +async def test_humidifier_set_humidity( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set humidity service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Test set humidity + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 23}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 23.0 + + +async def test_humidifier_set_humidity_while_off( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set humidity service while off.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Switch humidifier off + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Try setting humidity + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HUMIDITY: 23}, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "humidity_while_off" + + +async def test_humidifier_set_mode( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set mode service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MODE: MODE_AUTO}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_MODE] == MODE_AUTO + + +async def test_humidifier_set_status( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test humidifier set status service.""" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON + assert state.attributes[ATTR_HUMIDITY] == 50.0 + + # Test turn off + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + # Test turn on + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_serial_bridge.set_humidity_status.assert_called() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_ON From b01eac3ba5f9528275fbc2d39c21b60f7620c217 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 11 Apr 2025 19:39:40 +0200 Subject: [PATCH 009/116] Fix error in recurrence calculation of Habitica integration (#142759) Fix error in rrule calculation of Habitica integration --- homeassistant/components/habitica/util.py | 2 +- tests/components/habitica/fixtures/tasks.json | 43 +++ tests/components/habitica/fixtures/user.json | 3 +- .../habitica/snapshots/test_calendar.ambr | 28 ++ .../habitica/snapshots/test_services.ambr | 300 ++++++++++++++++++ .../habitica/snapshots/test_todo.ambr | 9 +- 6 files changed, 382 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 757c675b045..1ca908eb3ff 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -74,7 +74,7 @@ def build_rrule(task: TaskData) -> rrule: bysetpos = None if rrule_frequency == MONTHLY and task.weeksOfMonth: - bysetpos = task.weeksOfMonth + bysetpos = [i + 1 for i in task.weeksOfMonth] weekdays = weekdays if weekdays else [MO] return rrule( diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 085508b4432..ecbe0a1f86d 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -624,6 +624,49 @@ "isDue": false, "id": "6e53f1f5-a315-4edd-984d-8d762e4a08ef" }, + { + "repeat": { + "m": false, + "t": false, + "w": false, + "th": false, + "f": false, + "s": false, + "su": true + }, + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "_id": "369afeed-61e3-4bf7-9747-66e05807134c", + "frequency": "monthly", + "everyX": 1, + "streak": 1, + "nextDue": ["2024-12-14T23:00:00.000Z", "2025-01-18T23:00:00.000Z"], + "yesterDaily": true, + "history": [], + "completed": false, + "collapseChecklist": false, + "type": "daily", + "text": "Monatliche Finanzübersicht erstellen", + "notes": "Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.", + "tags": [], + "value": -0.9215181434950852, + "priority": 1, + "attribute": "str", + "byHabitica": false, + "startDate": "2024-04-04T22:00:00.000Z", + "daysOfMonth": [], + "weeksOfMonth": [0], + "checklist": [], + "reminders": [], + "createdAt": "2024-04-04T22:00:00.000Z", + "updatedAt": "2024-04-04T22:00:00.000Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "isDue": false, + "id": "369afeed-61e3-4bf7-9747-66e05807134c" + }, { "repeat": { "m": false, diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 58eca2837b6..d2f0091b6dd 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -66,7 +66,8 @@ "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", "f2c85972-1a19-4426-bc6d-ce3337b9d99f", "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", - "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + "6e53f1f5-a315-4edd-984d-8d762e4a08ef", + "369afeed-61e3-4bf7-9747-66e05807134c" ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 2948f31f1cf..c7f12684efe 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -87,6 +87,20 @@ 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), + dict({ + 'description': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', + 'end': dict({ + 'date': '2024-09-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=MONTHLY;BYSETPOS=4;BYDAY=SU', + 'start': dict({ + 'date': '2024-09-22', + }), + 'summary': 'Arbeite an einem kreativen Projekt', + 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', + }), dict({ 'description': 'Klicke um Änderungen zu machen!', 'end': dict({ @@ -563,6 +577,20 @@ 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), + dict({ + 'description': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'end': dict({ + 'date': '2024-10-07', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': 'FREQ=MONTHLY;BYSETPOS=1;BYDAY=SU', + 'start': dict({ + 'date': '2024-10-06', + }), + 'summary': 'Monatliche Finanzübersicht erstellen', + 'uid': '369afeed-61e3-4bf7-9747-66e05807134c', + }), dict({ 'description': 'Klicke um Änderungen zu machen!', 'end': dict({ diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index 430cd379c0d..9fbb6a43e94 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1193,6 +1193,81 @@ ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -3465,6 +3540,81 @@ ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -4608,6 +4758,81 @@ ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', @@ -5199,6 +5424,81 @@ ]), 'yesterDaily': True, }), + dict({ + 'alias': None, + 'attribute': 'str', + 'byHabitica': False, + 'challenge': dict({ + 'broken': None, + 'id': None, + 'shortName': None, + 'taskId': None, + 'winner': None, + }), + 'checklist': list([ + ]), + 'collapseChecklist': False, + 'completed': False, + 'counterDown': None, + 'counterUp': None, + 'createdAt': '2024-04-04T22:00:00+00:00', + 'date': None, + 'daysOfMonth': list([ + ]), + 'down': None, + 'everyX': 1, + 'frequency': 'monthly', + 'group': dict({ + 'assignedDate': None, + 'assignedUsers': list([ + ]), + 'assignedUsersDetail': dict({ + }), + 'assigningUsername': None, + 'completedBy': dict({ + 'date': None, + 'userId': None, + }), + 'id': None, + 'managerNotes': None, + 'taskId': None, + }), + 'history': list([ + ]), + 'id': '369afeed-61e3-4bf7-9747-66e05807134c', + 'isDue': False, + 'nextDue': list([ + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + ]), + 'notes': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'priority': 1, + 'reminders': list([ + ]), + 'repeat': dict({ + 'f': False, + 'm': False, + 's': False, + 'su': True, + 't': False, + 'th': False, + 'w': False, + }), + 'startDate': '2024-04-04T22:00:00+00:00', + 'streak': 1, + 'tags': list([ + ]), + 'text': 'Monatliche Finanzübersicht erstellen', + 'type': 'daily', + 'up': None, + 'updatedAt': '2024-04-04T22:00:00+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', + 'value': -0.9215181434950852, + 'weeksOfMonth': list([ + 0, + ]), + 'yesterDaily': True, + }), dict({ 'alias': None, 'attribute': 'str', diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 88204d53ded..fef9404a0f0 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -49,6 +49,13 @@ 'summary': 'Arbeite an einem kreativen Projekt', 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', }), + dict({ + 'description': 'Setze dich einmal im Monat hin, um deine Einnahmen und Ausgaben zu überprüfen und dein Budget zu planen.', + 'due': '2024-12-14', + 'status': 'needs_action', + 'summary': 'Monatliche Finanzübersicht erstellen', + 'uid': '369afeed-61e3-4bf7-9747-66e05807134c', + }), dict({ 'description': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', 'status': 'needs_action', @@ -151,7 +158,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4', + 'state': '5', }) # --- # name: test_todos[todo.test_user_to_do_s-entry] From ffcc2254ceb6a5bfda2249c51528d87f0d8c0c19 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Apr 2025 19:40:37 +0200 Subject: [PATCH 010/116] Refactor Syncthru binary sensor (#142696) --- .../components/syncthru/binary_sensor.py | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 6f6bd73af77..d863c5546d8 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -2,11 +2,15 @@ from __future__ import annotations -from pysyncthru import SyncthruState +from collections.abc import Callable +from dataclasses import dataclass + +from pysyncthru import SyncThru, SyncthruState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME @@ -29,6 +33,27 @@ SYNCTHRU_STATE_PROBLEM = { } +@dataclass(frozen=True, kw_only=True) +class SyncThruBinarySensorDescription(BinarySensorEntityDescription): + """Describes Syncthru binary sensor entities.""" + + value_fn: Callable[[SyncThru], bool | None] + + +BINARY_SENSORS: tuple[SyncThruBinarySensorDescription, ...] = ( + SyncThruBinarySensorDescription( + key="online", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda printer: printer.is_online(), + ), + SyncThruBinarySensorDescription( + key="problem", + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda printer: SYNCTHRU_STATE_PROBLEM[printer.device_status()], + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -39,67 +64,42 @@ async def async_setup_entry( coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] name: str = config_entry.data[CONF_NAME] - entities = [ - SyncThruOnlineSensor(coordinator, name), - SyncThruProblemSensor(coordinator, name), - ] - async_add_entities(entities) + async_add_entities( + SyncThruBinarySensor(coordinator, name, description) + for description in BINARY_SENSORS + ) class SyncThruBinarySensor(CoordinatorEntity[SyncthruCoordinator], BinarySensorEntity): """Implementation of an abstract Samsung Printer binary sensor platform.""" - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: + entity_description: SyncThruBinarySensorDescription + + def __init__( + self, + coordinator: SyncthruCoordinator, + name: str, + entity_description: SyncThruBinarySensorDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self.syncthru = coordinator.data + self.entity_description = entity_description + serial_number = coordinator.data.serial_number() + assert serial_number is not None + self._attr_unique_id = f"{serial_number}_{entity_description.key}" self._attr_name = name - self._id_suffix = "" - - @property - def unique_id(self): - """Return unique ID for the sensor.""" - serial = self.syncthru.serial_number() - return f"{serial}{self._id_suffix}" if serial else None @property def device_info(self) -> DeviceInfo | None: """Return device information.""" - if (identifiers := device_identifiers(self.syncthru)) is None: + if (identifiers := device_identifiers(self.coordinator.data)) is None: return None return DeviceInfo( identifiers=identifiers, ) - -class SyncThruOnlineSensor(SyncThruBinarySensor): - """Implementation of a sensor that checks whether is turned on/online.""" - - _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY - - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_online" - @property - def is_on(self): - """Set the state to whether the printer is online.""" - return self.syncthru.is_online() - - -class SyncThruProblemSensor(SyncThruBinarySensor): - """Implementation of a sensor that checks whether the printer works correctly.""" - - _attr_device_class = BinarySensorDeviceClass.PROBLEM - - def __init__(self, coordinator: SyncthruCoordinator, name: str) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, name) - self._id_suffix = "_problem" - - @property - def is_on(self): - """Set the state to whether there is a problem with the printer.""" - return SYNCTHRU_STATE_PROBLEM[self.syncthru.device_status()] + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) From 0fcac987dffcf92ac65a95123c79ebdbc332c3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 11 Apr 2025 19:21:53 +0100 Subject: [PATCH 011/116] Update strings for Whirlpool config flows (#142758) --- homeassistant/components/whirlpool/strings.json | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 1cb5344b238..8f38330980e 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -13,19 +13,23 @@ "brand": "Brand" }, "data_description": { - "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account" + "username": "The username or email address you use to log in to the Whirlpool/Maytag app", + "password": "The password you use to log in to the Whirlpool/Maytag app", + "region": "The region where your appliances where purchased", + "brand": "The brand of the mobile app you use, or the brand of the appliances in your account" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "For 'brand', please choose the brand of the mobile app you use, or the brand of the appliances in your account", "data": { "password": "[%key:common::config_flow::data::password%]", - "region": "Region", - "brand": "Brand" + "region": "[%key:component::whirlpool::config::step::user::data::region%]", + "brand": "[%key:component::whirlpool::config::step::user::data::brand%]" }, "data_description": { - "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account" + "password": "[%key:component::whirlpool::config::step::user::data_description::password%]", + "brand": "[%key:component::whirlpool::config::step::user::data_description::brand%]", + "region": "[%key:component::whirlpool::config::step::user::data_description::region%]" } } }, From 9d10d8f55e1b93a85812249ec9a8c082a8c67382 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 11 Apr 2025 20:27:45 +0200 Subject: [PATCH 012/116] Fix slack DeprecationWarnings (#142754) --- homeassistant/components/slack/__init__.py | 2 +- homeassistant/components/slack/config_flow.py | 2 +- homeassistant/components/slack/notify.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index aa67739016d..899b46ee7e8 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from aiohttp.client_exceptions import ClientError -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index fcdc2e8b362..551e9832b2b 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient import voluptuous as vol diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 16dd212301a..4c7f52e581f 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse from aiohttp import BasicAuth from aiohttp.client_exceptions import ClientError -from slack.errors import SlackApiError +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient import voluptuous as vol From 2c316c582033e5e805a91e2a0b4bf8e3da1b6327 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Apr 2025 09:39:30 -1000 Subject: [PATCH 013/116] Ensure person loads after recorder (#142585) Co-authored-by: Erik Montnemery --- homeassistant/bootstrap.py | 1 + homeassistant/components/onboarding/manifest.json | 2 +- tests/test_bootstrap.py | 11 ++++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 962c7871028..f88912478a7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -53,6 +53,7 @@ from .components import ( logbook as logbook_pre_import, # noqa: F401 lovelace as lovelace_pre_import, # noqa: F401 onboarding as onboarding_pre_import, # noqa: F401 + person as person_pre_import, # noqa: F401 recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements repairs as repairs_pre_import, # noqa: F401 search as search_pre_import, # noqa: F401 diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index a4cf814eb2a..e57857896e0 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -2,7 +2,7 @@ "domain": "onboarding", "name": "Home Assistant Onboarding", "codeowners": ["@home-assistant/core"], - "dependencies": ["auth", "http", "person"], + "dependencies": ["auth", "http"], "documentation": "https://www.home-assistant.io/integrations/onboarding", "integration_type": "system", "quality_scale": "internal" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 7a4f9fda257..ebfc6b81e00 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -924,7 +924,7 @@ async def test_setup_hass_invalid_core_config( "external_url": "https://abcdef.ui.nabu.casa", }, "map": {}, - "person": {"invalid": True}, + "frontend": {"invalid": True}, } ], ) @@ -1560,6 +1560,11 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> # we remove the platform YAML schema support for sensors "websocket_api": {"sensor.py"}, } + # person is a special case because it is a base platform + # in the sense that it creates entities in its namespace + # but its not used by other integrations to create entities + # so we want to make sure it is not loaded before the recorder + base_platforms = BASE_PLATFORMS | {"person"} integrations_before_recorder: set[str] = set() for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: @@ -1592,7 +1597,7 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> problems: dict[str, set[str]] = {} for domain in integrations: domain_with_base_platforms_deps = ( - integrations_all_dependencies[domain] & BASE_PLATFORMS + integrations_all_dependencies[domain] & base_platforms ) if domain_with_base_platforms_deps: problems[domain] = domain_with_base_platforms_deps @@ -1600,7 +1605,7 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> f"Integrations that are setup before recorder have base platforms in their dependencies: {problems}" ) - base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} + base_platform_py_files = {f"{base_platform}.py" for base_platform in base_platforms} for domain, integration in all_integrations.items(): integration_base_platforms_files = ( From 4f0ece1bb43f7f51e90f18cd0afc92d48c5ed36f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 11 Apr 2025 22:37:15 +0200 Subject: [PATCH 014/116] Update uiprotect to 7.5.3 (#142766) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a4bb6d20841..7cbb6128eef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.5.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.5.3", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index fb8acbf1527..187b8908cc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2968,7 +2968,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.1 +uiprotect==7.5.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc6b5bc0e8e..e5c3cdd9c39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2394,7 +2394,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.5.1 +uiprotect==7.5.3 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From b20f46e8b901cead43f34bacc216d735b4ad160e Mon Sep 17 00:00:00 2001 From: Mathijs van de Nes Date: Fri, 11 Apr 2025 22:55:05 +0200 Subject: [PATCH 015/116] Add non-shared ssl client_context (#142653) --- homeassistant/util/ssl.py | 20 ++++++++++++++++++-- tests/util/test_ssl.py | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index a22fd0c8fb4..4e26a126f39 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -82,10 +82,10 @@ def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: return sslcontext -@cache -def _client_context( +def _create_client_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: + """Return an independent SSL context for making requests.""" # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -100,6 +100,14 @@ def _client_context( return sslcontext +@cache +def _client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + # Cached version of _create_client_context + return _create_client_context(ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT) _DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT) @@ -139,6 +147,14 @@ def client_context( return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT) +def create_client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an independent SSL context for making requests.""" + # This explicitly uses the non-cached version to create a client context + return _create_client_context(ssl_cipher_list) + + def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index c0cd2fdba10..0c30ad9b9b3 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -7,6 +7,7 @@ import pytest from homeassistant.util.ssl import ( SSLCipherList, client_context, + create_client_context, create_no_verify_ssl_context, ) @@ -56,3 +57,28 @@ def test_ssl_context_caching() -> None: assert create_no_verify_ssl_context() is create_no_verify_ssl_context( SSLCipherList.PYTHON_DEFAULT ) + + +def test_cteate_client_context(mock_sslcontext) -> None: + """Test create client context.""" + with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext): + client_context() + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.MODERN) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INTERMEDIATE) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INSECURE) + mock_sslcontext.set_ciphers.assert_not_called() + + +def test_create_client_context_independent() -> None: + """Test create_client_context independence.""" + shared_context = client_context() + independent_context_1 = create_client_context() + independent_context_2 = create_client_context() + assert shared_context is not independent_context_1 + assert independent_context_1 is not independent_context_2 From 3efb009e82aa8be15ab8669f7be400ff12917c18 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 00:12:23 +0200 Subject: [PATCH 016/116] Introduce base entity in Syncthru (#142694) --- homeassistant/components/syncthru/__init__.py | 29 +------------------ .../components/syncthru/binary_sensor.py | 16 ++-------- homeassistant/components/syncthru/entity.py | 28 ++++++++++++++++++ homeassistant/components/syncthru/sensor.py | 16 ++-------- 4 files changed, 35 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/syncthru/entity.py diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index b6e7c8a70c9..016d0de7257 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from pysyncthru import SyncThru, SyncThruAPINotSupported +from pysyncthru import SyncThruAPINotSupported from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import SyncthruCoordinator @@ -26,17 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # and the config should simply be discarded return False - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - configuration_url=coordinator.syncthru.url, - connections=device_connections(coordinator.syncthru), - manufacturer="Samsung", - identifiers=device_identifiers(coordinator.syncthru), - model=coordinator.syncthru.model(), - name=coordinator.syncthru.hostname(), - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -46,18 +34,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) hass.data[DOMAIN].pop(entry.entry_id, None) return unload_ok - - -def device_identifiers(printer: SyncThru) -> set[tuple[str, str]] | None: - """Get device identifiers for device registry.""" - serial = printer.serial_number() - if serial is None: - return None - return {(DOMAIN, serial)} - - -def device_connections(printer: SyncThru) -> set[tuple[str, str]]: - """Get device connections for device registry.""" - if mac := printer.raw().get("identity", {}).get("mac_addr"): - return {(dr.CONNECTION_NETWORK_MAC, mac)} - return set() diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index d863c5546d8..72157b9a22d 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -15,12 +15,11 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SyncthruCoordinator, device_identifiers +from . import SyncthruCoordinator from .const import DOMAIN +from .entity import SyncthruEntity SYNCTHRU_STATE_PROBLEM = { SyncthruState.INVALID: True, @@ -71,7 +70,7 @@ async def async_setup_entry( ) -class SyncThruBinarySensor(CoordinatorEntity[SyncthruCoordinator], BinarySensorEntity): +class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity): """Implementation of an abstract Samsung Printer binary sensor platform.""" entity_description: SyncThruBinarySensorDescription @@ -90,15 +89,6 @@ class SyncThruBinarySensor(CoordinatorEntity[SyncthruCoordinator], BinarySensorE self._attr_unique_id = f"{serial_number}_{entity_description.key}" self._attr_name = name - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if (identifiers := device_identifiers(self.coordinator.data)) is None: - return None - return DeviceInfo( - identifiers=identifiers, - ) - @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py new file mode 100644 index 00000000000..fa3fbb0f2f4 --- /dev/null +++ b/homeassistant/components/syncthru/entity.py @@ -0,0 +1,28 @@ +"""Base class for Syncthru entities.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DOMAIN, SyncthruCoordinator + + +class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): + """Base class for Syncthru entities.""" + + def __init__(self, coordinator: SyncthruCoordinator) -> None: + """Initialize the Syncthru entity.""" + super().__init__(coordinator) + serial_number = coordinator.syncthru.serial_number() + assert serial_number is not None + connections = set() + if mac := coordinator.syncthru.raw().get("identity", {}).get("mac_addr"): + connections.add((dr.CONNECTION_NETWORK_MAC, mac)) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + connections=connections, + configuration_url=coordinator.syncthru.url, + manufacturer="Samsung", + model=coordinator.syncthru.model(), + name=coordinator.syncthru.hostname(), + ) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 3f4c802e62d..022d48b463f 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -12,12 +12,11 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SyncthruCoordinator, device_identifiers +from . import SyncthruCoordinator from .const import DOMAIN +from .entity import SyncthruEntity SYNCTHRU_STATE_HUMAN = { SyncthruState.INVALID: "invalid", @@ -138,7 +137,7 @@ async def async_setup_entry( ) -class SyncThruSensor(CoordinatorEntity[SyncthruCoordinator], SensorEntity): +class SyncThruSensor(SyncthruEntity, SensorEntity): """Implementation of an abstract Samsung Printer sensor platform.""" _attr_icon = "mdi:printer" @@ -159,15 +158,6 @@ class SyncThruSensor(CoordinatorEntity[SyncthruCoordinator], SensorEntity): assert serial_number is not None self._attr_unique_id = f"{serial_number}_{entity_description.key}" - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if (identifiers := device_identifiers(self.syncthru)) is None: - return None - return DeviceInfo( - identifiers=identifiers, - ) - @property def native_value(self) -> str | int | None: """Return the state of the sensor.""" From c18d96e2f5bbb4c0177d87319eae78bb5a251e3f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 12 Apr 2025 00:15:15 +0200 Subject: [PATCH 017/116] UniFi redact WLAN password (#142767) * Recact password key word in WLAN diagnostic data * Fix testdata --- homeassistant/components/unifi/diagnostics.py | 2 +- .../unifi/snapshots/test_diagnostics.ambr | 78 ++++++++++++++++++- tests/components/unifi/test_diagnostics.py | 70 +++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index 21174342594..49a9b678b0f 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -27,7 +27,7 @@ REDACT_DEVICES = { "x_ssh_hostkey_fingerprint", "x_vwirekey", } -REDACT_WLANS = {"bc_filter_list", "x_passphrase"} +REDACT_WLANS = {"bc_filter_list", "password", "x_passphrase"} @callback diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index aa7337be0ba..04aec0541b9 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entry_diagnostics[dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] +# name: test_entry_diagnostics[wlan_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] dict({ 'clients': dict({ '00:00:00:00:00:00': dict({ @@ -128,6 +128,82 @@ }), 'role_is_admin': True, 'wlans': dict({ + '67f2eaec026b2c2893c41b2a': dict({ + '_id': '67f2eaec026b2c2893c41b2a', + 'ap_group_ids': list([ + '67f2e03f7c572754fa1a249e', + ]), + 'ap_group_mode': 'all', + 'bc_filter_list': '**REDACTED**', + 'bss_transition': True, + 'dtim_6e': 3, + 'dtim_mode': 'default', + 'dtim_na': 3, + 'dtim_ng': 1, + 'enabled': True, + 'enhanced_iot': False, + 'fast_roaming_enabled': False, + 'group_rekey': 3600, + 'hide_ssid': False, + 'hotspot2conf_enabled': False, + 'iapp_enabled': True, + 'is_guest': False, + 'l2_isolation': False, + 'mac_filter_enabled': False, + 'mac_filter_list': list([ + ]), + 'mac_filter_policy': 'allow', + 'mcastenhance_enabled': False, + 'minrate_na_advertising_rates': False, + 'minrate_na_data_rate_kbps': 6000, + 'minrate_na_enabled': False, + 'minrate_ng_advertising_rates': False, + 'minrate_ng_data_rate_kbps': 1000, + 'minrate_ng_enabled': True, + 'minrate_setting_preference': 'auto', + 'mlo_enabled': False, + 'name': 'devices', + 'networkconf_id': '67f2e03f7c572754fa1a2498', + 'no2ghz_oui': True, + 'passphrase_autogenerated': True, + 'pmf_mode': 'disabled', + 'private_preshared_keys': list([ + dict({ + 'networkconf_id': '67f2e03f7c572754fa1a2498', + 'password': '**REDACTED**', + }), + ]), + 'private_preshared_keys_enabled': True, + 'proxy_arp': False, + 'radius_das_enabled': False, + 'radius_mac_auth_enabled': False, + 'radius_macacl_format': 'none_lower', + 'sae_anti_clogging': 5, + 'sae_groups': list([ + ]), + 'sae_psk': list([ + ]), + 'sae_sync': 5, + 'schedule': list([ + ]), + 'schedule_with_duration': list([ + ]), + 'security': 'wpapsk', + 'setting_preference': 'manual', + 'site_id': '67f2e00e7c572754fa1a247e', + 'uapsd_enabled': False, + 'usergroup_id': '67f2e03f7c572754fa1a2499', + 'wlan_band': '2g', + 'wlan_bands': list([ + '2g', + ]), + 'wpa3_fast_roaming': False, + 'wpa3_support': False, + 'wpa3_transition': False, + 'wpa_enc': 'ccmp', + 'wpa_mode': 'wpa2', + 'x_passphrase': '**REDACTED**', + }), }), }) # --- diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 80359a9c75c..e9fd86f0f8b 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -103,6 +103,75 @@ DPI_GROUP_DATA = [ "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], } ] +WLAN_DATA = [ + { + "setting_preference": "manual", + "wpa3_support": False, + "dtim_6e": 3, + "minrate_na_advertising_rates": False, + "wpa_mode": "wpa2", + "minrate_setting_preference": "auto", + "minrate_ng_advertising_rates": False, + "hotspot2conf_enabled": False, + "radius_das_enabled": False, + "mlo_enabled": False, + "group_rekey": 3600, + "radius_macacl_format": "none_lower", + "pmf_mode": "disabled", + "wpa3_transition": False, + "passphrase_autogenerated": True, + "private_preshared_keys": [ + { + "password": "should be redacted", + "networkconf_id": "67f2e03f7c572754fa1a2498", + } + ], + "mcastenhance_enabled": False, + "usergroup_id": "67f2e03f7c572754fa1a2499", + "proxy_arp": False, + "sae_sync": 5, + "iapp_enabled": True, + "uapsd_enabled": False, + "enhanced_iot": False, + "name": "devices", + "site_id": "67f2e00e7c572754fa1a247e", + "hide_ssid": False, + "wlan_band": "2g", + "_id": "67f2eaec026b2c2893c41b2a", + "private_preshared_keys_enabled": True, + "no2ghz_oui": True, + "networkconf_id": "67f2e03f7c572754fa1a2498", + "is_guest": False, + "dtim_na": 3, + "minrate_na_enabled": False, + "sae_groups": [], + "enabled": True, + "sae_psk": [], + "wlan_bands": ["2g"], + "mac_filter_policy": "allow", + "security": "wpapsk", + "ap_group_ids": ["67f2e03f7c572754fa1a249e"], + "l2_isolation": False, + "minrate_ng_enabled": True, + "bss_transition": True, + "minrate_ng_data_rate_kbps": 1000, + "radius_mac_auth_enabled": False, + "schedule_with_duration": [], + "wpa3_fast_roaming": False, + "ap_group_mode": "all", + "fast_roaming_enabled": False, + "wpa_enc": "ccmp", + "mac_filter_list": [], + "dtim_mode": "default", + "schedule": [], + "bc_filter_list": "should be redacted", + "minrate_na_data_rate_kbps": 6000, + "mac_filter_enabled": False, + "sae_anti_clogging": 5, + "dtim_ng": 1, + "x_passphrase": "should be redacted", + } +] @pytest.mark.parametrize( @@ -119,6 +188,7 @@ DPI_GROUP_DATA = [ @pytest.mark.parametrize("device_payload", [DEVICE_DATA]) @pytest.mark.parametrize("dpi_app_payload", [DPI_APP_DATA]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUP_DATA]) +@pytest.mark.parametrize("wlan_payload", [WLAN_DATA]) async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 49721a541a0261d08e9085948688c6a7f97b33f2 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 12 Apr 2025 00:16:14 +0200 Subject: [PATCH 018/116] bump xiaomi-ble to 0.36.0 (#142761) --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index d7156246d38..ed534387114 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.35.0"] + "requirements": ["xiaomi-ble==0.36.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 187b8908cc6..c3b9d83d625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3094,7 +3094,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.35.0 +xiaomi-ble==0.36.0 # homeassistant.components.knx xknx==3.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5c3cdd9c39..4bf005bb439 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2499,7 +2499,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.35.0 +xiaomi-ble==0.36.0 # homeassistant.components.knx xknx==3.6.0 From ee37b32ca15d08651fff1669b68f22532a166b99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 11 Apr 2025 13:57:47 -1000 Subject: [PATCH 019/116] Log lutron_caseta exception on pairing failure (#140776) --- homeassistant/components/lutron_caseta/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 45e7a04bdc9..115da5cb101 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -123,7 +123,8 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): assets = None try: assets = await async_pair(self.data[CONF_HOST]) - except (TimeoutError, OSError): + except (TimeoutError, OSError) as exc: + _LOGGER.debug("Pairing failed", exc_info=exc) errors["base"] = "cannot_connect" if not errors: From ad3c4d24b8cab7b764bd28b3f38864916acbd10d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 12 Apr 2025 02:08:09 +0200 Subject: [PATCH 020/116] Update h2 to 4.2.0 (#142777) --- homeassistant/components/iaqualink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index 7e05bd72f0b..a0742865438 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/iaqualink", "iot_class": "cloud_polling", "loggers": ["iaqualink"], - "requirements": ["iaqualink==0.5.3", "h2==4.1.0"], + "requirements": ["iaqualink==0.5.3", "h2==4.2.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c3b9d83d625..d6972d363fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1096,7 +1096,7 @@ gstreamer-player==1.1.2 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.1.0 +h2==4.2.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bf005bb439..9013ba24c57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ gspread==5.5.0 guppy3==3.1.5 # homeassistant.components.iaqualink -h2==4.1.0 +h2==4.2.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.2.2 From 234c4c1958e78929ca760087f27618e96833ef87 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 12 Apr 2025 09:41:54 +0200 Subject: [PATCH 021/116] Move backup backup onboarding API to an onboarding platform (#142713) * Move backup backup onboarding API to an onboarding platform * Move additional test from onboarding to backup * Remove backup tests from onboarding --- homeassistant/components/backup/onboarding.py | 143 ++++++ homeassistant/components/onboarding/views.py | 123 +----- script/hassfest/dependencies.py | 4 - .../snapshots/test_onboarding.ambr} | 0 tests/components/backup/test_onboarding.py | 414 ++++++++++++++++++ tests/components/onboarding/test_views.py | 362 +-------------- 6 files changed, 560 insertions(+), 486 deletions(-) create mode 100644 homeassistant/components/backup/onboarding.py rename tests/components/{onboarding/snapshots/test_views.ambr => backup/snapshots/test_onboarding.ambr} (100%) create mode 100644 tests/components/backup/test_onboarding.py diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py new file mode 100644 index 00000000000..1bbd3937567 --- /dev/null +++ b/homeassistant/components/backup/onboarding.py @@ -0,0 +1,143 @@ +"""Backup onboarding views.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from functools import wraps +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Concatenate + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized +import voluptuous as vol + +from homeassistant.components.http import KEY_HASS +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.onboarding import ( + BaseOnboardingView, + NoAuthBaseOnboardingView, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager + +from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http + +if TYPE_CHECKING: + from homeassistant.components.onboarding import OnboardingStoreData + + +async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the backup views.""" + + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) + + +def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + try: + manager = await async_get_backup_manager(request.app[KEY_HASS]) + except HomeAssistantError: + return self.json( + {"code": "backup_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(NoAuthBaseOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": list(backups.values()), + "state": manager.state, + "last_action_event": manager.last_action_event, + } + ) + + +class RestoreBackupView(NoAuthBaseOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + except HomeAssistantError as err: + return self.json( + {"code": "restore_failed", "message": str(err)}, + status_code=HTTPStatus.BAD_REQUEST, + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index e9d163a1bbb..bbe198f0d2f 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,11 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine -from functools import wraps from http import HTTPStatus import logging -from typing import TYPE_CHECKING, Any, Concatenate, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -17,19 +15,11 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth -from homeassistant.components.backup import ( - BackupManager, - Folder, - IncorrectPasswordError, - http as backup_http, -) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar, integration_platform -from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component, async_wait_component @@ -61,9 +51,6 @@ async def async_setup( hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) - hass.http.register_view(BackupInfoView(data)) - hass.http.register_view(RestoreBackupView(data)) - hass.http.register_view(UploadBackupView(data)) hass.http.register_view(WaitIntegrationOnboardingView(data)) @@ -377,114 +364,6 @@ class AnalyticsOnboardingView(_BaseOnboardingStepView): return self.json({}) -def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( - func: Callable[ - Concatenate[_ViewT, BackupManager, web.Request, _P], - Coroutine[Any, Any, web.Response], - ], -) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: - """Home Assistant API decorator to check onboarding and inject manager.""" - - @wraps(func) - async def with_backup( - self: _ViewT, - request: web.Request, - *args: _P.args, - **kwargs: _P.kwargs, - ) -> web.Response: - """Check admin and call function.""" - if self._data["done"]: - raise HTTPUnauthorized - - try: - manager = await async_get_backup_manager(request.app[KEY_HASS]) - except HomeAssistantError: - return self.json( - {"code": "backup_disabled"}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - return await func(self, manager, request, *args, **kwargs) - - return with_backup - - -class BackupInfoView(NoAuthBaseOnboardingView): - """Get backup info view.""" - - url = "/api/onboarding/backup/info" - name = "api:onboarding:backup:info" - - @with_backup_manager - async def get(self, manager: BackupManager, request: web.Request) -> web.Response: - """Return backup info.""" - backups, _ = await manager.async_get_backups() - return self.json( - { - "backups": list(backups.values()), - "state": manager.state, - "last_action_event": manager.last_action_event, - } - ) - - -class RestoreBackupView(NoAuthBaseOnboardingView): - """Restore backup view.""" - - url = "/api/onboarding/backup/restore" - name = "api:onboarding:backup:restore" - - @RequestDataValidator( - vol.Schema( - { - vol.Required("backup_id"): str, - vol.Required("agent_id"): str, - vol.Optional("password"): str, - vol.Optional("restore_addons"): [str], - vol.Optional("restore_database", default=True): bool, - vol.Optional("restore_folders"): [vol.Coerce(Folder)], - } - ) - ) - @with_backup_manager - async def post( - self, manager: BackupManager, request: web.Request, data: dict[str, Any] - ) -> web.Response: - """Restore a backup.""" - try: - await manager.async_restore_backup( - data["backup_id"], - agent_id=data["agent_id"], - password=data.get("password"), - restore_addons=data.get("restore_addons"), - restore_database=data["restore_database"], - restore_folders=data.get("restore_folders"), - restore_homeassistant=True, - ) - except IncorrectPasswordError: - return self.json( - {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST - ) - except HomeAssistantError as err: - return self.json( - {"code": "restore_failed", "message": str(err)}, - status_code=HTTPStatus.BAD_REQUEST, - ) - return web.Response(status=HTTPStatus.OK) - - -class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView): - """Upload backup view.""" - - url = "/api/onboarding/backup/upload" - name = "api:onboarding:backup:upload" - - @with_backup_manager - async def post(self, manager: BackupManager, request: web.Request) -> web.Response: - """Upload a backup file.""" - return await self._post(request) - - @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 8f541760269..370be8d66f1 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -173,10 +173,6 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), - # The onboarding integration provides limited backup for use - # during onboarding. The onboarding integration waits for the backup manager - # and to be ready before calling any backup functionality. - ("onboarding", "backup"), } diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/backup/snapshots/test_onboarding.ambr similarity index 100% rename from tests/components/onboarding/snapshots/test_views.ambr rename to tests/components/backup/snapshots/test_onboarding.ambr diff --git a/tests/components/backup/test_onboarding.py b/tests/components/backup/test_onboarding.py new file mode 100644 index 00000000000..7dfd57ec60a --- /dev/null +++ b/tests/components/backup/test_onboarding.py @@ -0,0 +1,414 @@ +"""Test the onboarding views.""" + +from io import StringIO +from typing import Any +from unittest.mock import ANY, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components import backup, onboarding +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component + +from tests.common import register_auth_provider +from tests.typing import ClientSessionGenerator + + +def mock_onboarding_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + "version": onboarding.STORAGE_VERSION, + "data": data, + } + + +@pytest.fixture(autouse=True) +def auth_active(hass: HomeAssistant) -> None: + """Ensure auth is always active.""" + hass.loop.run_until_complete( + register_auth_provider(hass, {"type": "homeassistant"}) + ) + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test raising after onboarding.""" + mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 401 + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 404 + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test backup info.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test restore backup.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['agent_id']" + }, + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + { + "message": "Message format incorrect: required key not provided @ data['backup_id']" + }, + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + { + "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" + }, + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + { + "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" + }, + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + {"code": "incorrect_password"}, + 1, + ), + # Home Assistant error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + HomeAssistantError("Boom!"), + 400, + {"code": "restore_failed", "message": "Boom!"}, + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_json: str, + restore_calls: int, +) -> None: + """Test restore backup fails.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == expected_json + assert len(mock_restore.mock_calls) == restore_calls + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Unexpected error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + Exception("Boom!"), + 500, + "500 Internal Server Error", + 1, + ), + ], +) +async def test_onboarding_backup_restore_unexpected_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test restore backup fails.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert (await resp.content.read()).decode().startswith(expected_message) + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test upload backup.""" + mock_onboarding_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 8040eb978d5..08acdc94afc 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,20 +3,16 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus -from io import StringIO import os from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest -from syrupy import SnapshotAssertion -from homeassistant.components import backup, onboarding +from homeassistant.components import onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from . import mock_storage @@ -632,13 +628,6 @@ async def test_onboarding_installation_type( ("method", "view", "kwargs"), [ ("get", "installation_type", {}), - ("get", "backup/info", {}), - ( - "post", - "backup/restore", - {"json": {"backup_id": "abc123", "agent_id": "test"}}, - ), - ("post", "backup/upload", {}), ], ) async def test_onboarding_view_after_done( @@ -723,353 +712,6 @@ async def test_complete_onboarding( listener_3.assert_called_once_with() -@pytest.mark.parametrize( - ("method", "view", "kwargs"), - [ - ("get", "backup/info", {}), - ( - "post", - "backup/restore", - {"json": {"backup_id": "abc123", "agent_id": "test"}}, - ), - ("post", "backup/upload", {}), - ], -) -async def test_onboarding_backup_view_without_backup( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - method: str, - view: str, - kwargs: dict[str, Any], -) -> None: - """Test interacting with backup wievs when backup integration is missing.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - - resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) - - assert resp.status == 500 - assert await resp.json() == {"code": "backup_disabled"} - - -async def test_onboarding_backup_info( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test backup info.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - backups = { - "abc123": backup.ManagerBackup( - addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], - agents={ - "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) - }, - backup_id="abc123", - date="1970-01-01T00:00:00.000Z", - database_included=True, - extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, - folders=[backup.Folder.MEDIA, backup.Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - failed_agent_ids=[], - with_automatic_settings=True, - ), - "def456": backup.ManagerBackup( - addons=[], - agents={ - "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) - }, - backup_id="def456", - date="1980-01-01T00:00:00.000Z", - database_included=False, - extra_metadata={ - "instance_id": "unknown_uuid", - "with_automatic_settings": True, - }, - folders=[backup.Folder.MEDIA, backup.Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test 2", - failed_agent_ids=[], - with_automatic_settings=None, - ), - } - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backups", - return_value=(backups, {}), - ): - resp = await client.get("/api/onboarding/backup/info") - - assert resp.status == 200 - assert await resp.json() == snapshot - - -@pytest.mark.parametrize( - ("params", "expected_kwargs"), - [ - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - { - "agent_id": "backup.local", - "password": None, - "restore_addons": None, - "restore_database": True, - "restore_folders": None, - "restore_homeassistant": True, - }, - ), - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1"], - "restore_database": True, - "restore_folders": ["media"], - }, - { - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1"], - "restore_database": True, - "restore_folders": [backup.Folder.MEDIA], - "restore_homeassistant": True, - }, - ), - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1", "addon_2"], - "restore_database": False, - "restore_folders": ["media", "share"], - }, - { - "agent_id": "backup.local", - "password": "hunter2", - "restore_addons": ["addon_1", "addon_2"], - "restore_database": False, - "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], - "restore_homeassistant": True, - }, - ), - ], -) -async def test_onboarding_backup_restore( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - expected_kwargs: dict[str, Any], -) -> None: - """Test restore backup.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - assert resp.status == 200 - mock_restore.assert_called_once_with("abc123", **expected_kwargs) - - -@pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), - [ - # Missing agent_id - ( - {"backup_id": "abc123"}, - None, - 400, - { - "message": "Message format incorrect: required key not provided @ data['agent_id']" - }, - 0, - ), - # Missing backup_id - ( - {"agent_id": "backup.local"}, - None, - 400, - { - "message": "Message format incorrect: required key not provided @ data['backup_id']" - }, - 0, - ), - # Invalid restore_database - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "restore_database": "yes_please", - }, - None, - 400, - { - "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" - }, - 0, - ), - # Invalid folder - ( - { - "backup_id": "abc123", - "agent_id": "backup.local", - "restore_folders": ["invalid"], - }, - None, - 400, - { - "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" - }, - 0, - ), - # Wrong password - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - backup.IncorrectPasswordError, - 400, - {"code": "incorrect_password"}, - 1, - ), - # Home Assistant error - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - HomeAssistantError("Boom!"), - 400, - {"code": "restore_failed", "message": "Boom!"}, - 1, - ), - ], -) -async def test_onboarding_backup_restore_error( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - restore_error: Exception | None, - expected_status: int, - expected_json: str, - restore_calls: int, -) -> None: - """Test restore backup fails.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - side_effect=restore_error, - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - - assert resp.status == expected_status - assert await resp.json() == expected_json - assert len(mock_restore.mock_calls) == restore_calls - - -@pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), - [ - # Unexpected error - ( - {"backup_id": "abc123", "agent_id": "backup.local"}, - Exception("Boom!"), - 500, - "500 Internal Server Error", - 1, - ), - ], -) -async def test_onboarding_backup_restore_unexpected_error( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - params: dict[str, Any], - restore_error: Exception | None, - expected_status: int, - expected_message: str, - restore_calls: int, -) -> None: - """Test restore backup fails.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_restore_backup", - side_effect=restore_error, - ) as mock_restore: - resp = await client.post("/api/onboarding/backup/restore", json=params) - - assert resp.status == expected_status - assert (await resp.content.read()).decode().startswith(expected_message) - assert len(mock_restore.mock_calls) == restore_calls - - -async def test_onboarding_backup_upload( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, -) -> None: - """Test upload backup.""" - mock_storage(hass_storage, {"done": []}) - - assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_receive_backup", - return_value="abc123", - ) as mock_receive: - resp = await client.post( - "/api/onboarding/backup/upload?agent_id=backup.local", - data={"file": StringIO("test")}, - ) - assert resp.status == 201 - assert await resp.json() == {"backup_id": "abc123"} - mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) - - @pytest.mark.parametrize( ("domain", "expected_result"), [ From 4eda08157409de5e34069164335ab9b670db3685 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 12 Apr 2025 11:01:41 +0200 Subject: [PATCH 022/116] Remove unnecessary error handling from backup onboarding (#142786) --- homeassistant/components/backup/onboarding.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py index 1bbd3937567..ad7027c988c 100644 --- a/homeassistant/components/backup/onboarding.py +++ b/homeassistant/components/backup/onboarding.py @@ -54,14 +54,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( if self._data["done"]: raise HTTPUnauthorized - try: - manager = await async_get_backup_manager(request.app[KEY_HASS]) - except HomeAssistantError: - return self.json( - {"code": "backup_disabled"}, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - + manager = await async_get_backup_manager(request.app[KEY_HASS]) return await func(self, manager, request, *args, **kwargs) return with_backup From 6b65b21ee0db5778aa729c7af14dc354bb045f9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Apr 2025 00:20:07 -1000 Subject: [PATCH 023/116] Migrate inkbird to use entry.runtime_data (#142780) --- homeassistant/components/inkbird/__init__.py | 15 +++++++-------- homeassistant/components/inkbird/coordinator.py | 4 +++- homeassistant/components/inkbird/sensor.py | 11 +++-------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 738d412d849..bc81b852f02 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -8,27 +8,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TYPE, DOMAIN +from .const import CONF_DEVICE_TYPE from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] +INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) data = INKBIRDBluetoothDeviceData(device_type) coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe entry.async_on_unload(coordinator.async_start()) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py index bcd519b32aa..b119682a7d6 100644 --- a/homeassistant/components/inkbird/coordinator.py +++ b/homeassistant/components/inkbird/coordinator.py @@ -27,7 +27,9 @@ _LOGGER = logging.getLogger(__name__) FALLBACK_POLL_INTERVAL = timedelta(seconds=180) -class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): +class INKBIRDActiveBluetoothProcessorCoordinator( + ActiveBluetoothProcessorCoordinator[SensorUpdate] +): """Coordinator for INKBIRD Bluetooth devices.""" def __init__( diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index efda28b110d..447d7ac961b 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -4,12 +4,10 @@ from __future__ import annotations from inkbird_ble import DeviceClass, DeviceKey, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -27,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import INKBIRDConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( @@ -97,20 +95,17 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: INKBIRDConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the INKBIRD BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( INKBIRDBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload(entry.runtime_data.async_register_processor(processor)) class INKBIRDBluetoothSensorEntity( From eb19c7af32e34a4876aaf9bedcbcbe9cffe5299c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 12 Apr 2025 14:58:35 +0200 Subject: [PATCH 024/116] Disable Home Connect appliance refresh when frequent disconnects are detected (#142615) * Disable specific updates for an appliance when is done repeatedly * Fix deprecation issues fix tests * Fix message * Avoid fetching appliance info also * Apply suggestions Co-authored-by: Martin Hjelmare * Create specific RepairFlow for enabling appliance's updates --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 21 +- .../components/home_connect/coordinator.py | 68 ++++++- .../components/home_connect/repairs.py | 60 ++++++ .../components/home_connect/strings.json | 11 ++ .../home_connect/test_coordinator.py | 183 +++++++++++++++++- 5 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/home_connect/repairs.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index fe01a3e9564..38db34aa72a 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -11,9 +11,12 @@ import aiohttp from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import ( + config_entry_oauth2_flow, + config_validation as cv, + issue_registry as ir, +) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth @@ -86,8 +89,18 @@ async def async_unload_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: """Unload a config entry.""" - async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") - async_delete_issue(hass, DOMAIN, "deprecated_command_actions") + issue_registry = ir.async_get(hass) + issues_to_delete = [ + "deprecated_set_program_and_option_actions", + "deprecated_command_actions", + ] + [ + issue_id + for (issue_domain, issue_id) in issue_registry.issues + if issue_domain == DOMAIN + and issue_id.startswith("home_connect_too_many_connected_paired_events") + ] + for issue_id in issues_to_delete: + issue_registry.async_delete(DOMAIN, issue_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 54dc24a6279..4b4ec37ac61 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -39,7 +39,7 @@ 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, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN @@ -47,6 +47,9 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes +MAX_EXECUTIONS = 5 + type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] @@ -114,6 +117,7 @@ class HomeConnectCoordinator( ] = {} self.device_registry = dr.async_get(self.hass) self.data = {} + self._execution_tracker: dict[str, list[float]] = defaultdict(list) @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -172,7 +176,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: @@ -238,6 +242,9 @@ class HomeConnectCoordinator( self._call_event_listener(event_message) case EventType.CONNECTED | EventType.PAIRED: + if self.refreshed_too_often_recently(event_message_ha_id): + continue + appliance_info = await self.client.get_specific_appliance( event_message_ha_id ) @@ -592,3 +599,60 @@ class HomeConnectCoordinator( [], ): listener() + + def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: + """Check if the appliance data hasn't been refreshed too often recently.""" + + now = self.hass.loop.time() + if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: + return True + + execution_tracker = self._execution_tracker[appliance_ha_id] = [ + timestamp + for timestamp in self._execution_tracker[appliance_ha_id] + if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW + ] + + execution_tracker.append(now) + + if len(execution_tracker) >= MAX_EXECUTIONS: + ir.async_create_issue( + self.hass, + DOMAIN, + f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="home_connect_too_many_connected_paired_events", + data={ + "entry_id": self.config_entry.entry_id, + "appliance_ha_id": appliance_ha_id, + }, + translation_placeholders={ + "appliance_name": self.data[appliance_ha_id].info.name, + "times": str(MAX_EXECUTIONS), + "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), + "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", + "home_assistant_core_new_issue_url": ( + "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" + f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" + ), + }, + ) + return True + + return False + + async def reset_execution_tracker(self, appliance_ha_id: str) -> None: + """Reset the execution tracker for a specific appliance.""" + self._execution_tracker.pop(appliance_ha_id, None) + appliance_info = await self.client.get_specific_appliance(appliance_ha_id) + + appliance_data = await self._get_appliance_data( + appliance_info, self.data.get(appliance_info.ha_id) + ) + self.data[appliance_ha_id].update(appliance_data) + 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(appliance_ha_id) diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py new file mode 100644 index 00000000000..21c6775e549 --- /dev/null +++ b/homeassistant/components/home_connect/repairs.py @@ -0,0 +1,60 @@ +"""Repairs flows for Home Connect.""" + +from typing import cast + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .coordinator import HomeConnectConfigEntry + + +class EnableApplianceUpdatesFlow(RepairsFlow): + """Handler for enabling appliance's updates after being refreshed too many times.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + assert self.data + entry = self.hass.config_entries.async_get_entry( + cast(str, self.data["entry_id"]) + ) + assert entry + entry = cast(HomeConnectConfigEntry, entry) + await entry.runtime_data.reset_execution_tracker( + cast(str, self.data["appliance_ha_id"]) + ) + return self.async_create_entry(data={}) + + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("home_connect_too_many_connected_paired_events"): + return EnableApplianceUpdatesFlow() + return ConfirmRepairFlow() diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5b52183fccf..070dcf34f9c 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -110,6 +110,17 @@ } }, "issues": { + "home_connect_too_many_connected_paired_events": { + "title": "{appliance_name} sent too many connected or paired events", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", + "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." + } + } + } + }, "deprecated_time_alarm_clock_in_automations_scripts": { "title": "Deprecated alarm clock entity detected in some automations or scripts", "fix_flow": { diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index d3b514bcc17..a74c4199318 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta +from http import HTTPStatus from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -14,7 +15,9 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + GetSetting, HomeAppliance, + SettingKey, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -39,6 +42,8 @@ from homeassistant.config_entries import ConfigEntries, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_STATE_REPORTED, + STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, Platform, ) @@ -48,11 +53,16 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator INITIAL_FETCH_CLIENT_METHODS = [ "get_settings", @@ -609,3 +619,174 @@ async def test_paired_disconnected_devices_not_fetching( client.get_specific_appliance.assert_awaited_once_with(appliance.ha_id) for method in INITIAL_FETCH_CLIENT_METHODS: assert getattr(client, method).call_count == 0 + + +async def test_coordinator_disabling_updates_for_appliance( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test coordinator disables appliance updates on frequent connect/paired events. + + A repair issue should be created when the updates are disabled. + When the user confirms the issue the updates should be enabled again. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + assert resp.status == HTTPStatus.OK + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that updates are enabled again after unloading the entry. + + The repair issue should also be deleted. + """ + appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" + issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) + ] + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + get_settings_original_side_effect = client.get_settings.side_effect + + async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + if ha_id == appliance_ha_id: + return ArrayOfSettings( + [ + GetSetting( + SettingKey.BSH_COMMON_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE.value, + BSH_POWER_OFF, + ) + ] + ) + return cast(ArrayOfSettings, get_settings_original_side_effect(ha_id)) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) From 5129c7521b269a44231ddfc67901e94f524960ec Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:09:17 +0100 Subject: [PATCH 025/116] Force Squeezebox item id to string (#142793) force item_id to string --- .../components/squeezebox/browse_media.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 633f004993f..eadd706fcd8 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -148,7 +148,7 @@ def _build_response_apps_radios_category( ) -> BrowseMedia: """Build item for App or radio category.""" return BrowseMedia( - media_content_id=item.get("id", ""), + media_content_id=item["id"], title=item["title"], media_content_type=cmd, media_class=browse_data.content_type_media_class[cmd]["item"], @@ -163,7 +163,7 @@ def _build_response_known_app( """Build item for app or radio.""" return BrowseMedia( - media_content_id=item.get("id", ""), + media_content_id=item["id"], title=item["title"], media_content_type=search_type, media_class=browse_data.content_type_media_class[search_type]["item"], @@ -185,7 +185,7 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: ) if item["hasitems"] and not item["isaudio"]: return BrowseMedia( - media_content_id=item.get("id", ""), + media_content_id=item["id"], title=item["title"], media_content_type="Favorites", media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"]["item"], @@ -193,7 +193,7 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: can_play=False, ) return BrowseMedia( - media_content_id=item.get("id", ""), + media_content_id=item["id"], title=item["title"], media_content_type="Favorites", media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"], @@ -217,7 +217,7 @@ def _get_item_thumbnail( item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) elif item_type is not None: item_thumbnail = entity.get_browse_image_url( - item_type, item.get("id", ""), artwork_track_id + item_type, item["id"], artwork_track_id ) elif search_type in ["Apps", "Radios"]: @@ -263,6 +263,8 @@ async def build_item_response( children = [] for item in result["items"]: + # Force the item id to a string in case it's numeric from some lms + item["id"] = str(item.get("id", "")) if search_type == "Favorites": child_media = _build_response_favorites(item) @@ -294,7 +296,7 @@ async def build_item_response( elif item_type: child_media = BrowseMedia( - media_content_id=str(item.get("id", "")), + media_content_id=item["id"], title=item["title"], media_content_type=item_type, media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], From d218ac85f740f9635f05d9e9699d3d8f076e4bfd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 12 Apr 2025 19:15:38 +0200 Subject: [PATCH 026/116] Update pytest warnings filter (#142797) --- pyproject.toml | 105 +++++++++++++++++-------------------------------- 1 file changed, 37 insertions(+), 68 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 091bb617142..da076d1953d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -501,17 +501,18 @@ filterwarnings = [ # Modify app state for testing "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", - # -- Tests - # Ignore custom pytest marks - "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", - "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", + # -- Tests + # Ignore custom pytest marks + "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", + "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", + + # -- DeprecationWarning already fixed in our codebase + # https://github.com/kurtmckee/feedparser/pull/389 - 6.0.11 + "ignore:.*a temporary mapping .* from `updated_parsed` to `published_parsed` if `updated_parsed` doesn't exist:DeprecationWarning:feedparser.util", # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 + # https://github.com/gwww/elkm1/blob/2.2.11/elkm1_lib/util.py#L8-L19 "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/allenporter/ical/pull/215 - # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", @@ -522,28 +523,16 @@ filterwarnings = [ "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", # -- tracked upstream / open PRs - # - pyOpenSSL v24.2.1 - # https://github.com/certbot/certbot/issues/9828 - v2.11.0 - # https://github.com/certbot/certbot/issues/9992 - "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", - # - other - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 - # https://github.com/foxel/python_ndms2_client/pull/8 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", + # https://github.com/hacf-fr/meteofrance-api/pull/688 - v1.4.0 - 2025-03-26 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", # -- fixed, waiting for release / update - # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", - # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 @@ -551,27 +540,24 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/majuss/lupupy/pull/15 - >0.3.2 "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", - # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 + # https://github.com/nextcord/nextcord/pull/1095 - >=3.0.0 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 - # https://github.com/eclipse/paho.mqtt.python/pull/665 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 "ignore::DeprecationWarning:holidays", # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", + # https://github.com/rytilahti/python-miio/pull/1993 - >0.6.0.dev0 + "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", - # -- fixed for Python 3.13 - # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", - # -- other # Locale changes might take some time to resolve upstream # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://pypi.org/project/agent-py/ - v0.0.24 - 2024-11-07 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", # https://github.com/lidatong/dataclasses-json/issues/328 @@ -580,15 +566,22 @@ filterwarnings = [ # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 # https://github.com/martonperei/emulated_roku "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", - # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08 + # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 + # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", + # https://pypi.org/project/PyMetEireann/ - v2024.11.0 - 2024-11-23 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 + # https://github.com/lextudio/pysnmp/blob/v7.1.17/pysnmp/smi/compiler.py#L23-L31 - v7.1.17 - 2025-03-19 "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", - "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel + "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysnmp.smi.compiler", + # https://github.com/Python-roborock/python-roborock/issues/305 - 2.18.0 - 2025-04-06 + "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", # Wrong stacklevel @@ -620,35 +613,11 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", - # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 + # https://pypi.org/project/pybotvac/ - v0.0.26 - 2025-02-26 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # -- Python 3.13 - # HomeAssistant - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", - # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 - # https://github.com/nextcord/nextcord/issues/1174 - # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", - # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05 - # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 - "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", - # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 - # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", - - # -- Python 3.13 - unmaintained projects, last release about 2+ years - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", - # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", - # -- New in Python 3.13 # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 # https://github.com/kurtmckee/feedparser/issues/481 @@ -660,18 +629,22 @@ filterwarnings = [ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", + # -- Websockets 14.1 + # https://websockets.readthedocs.io/en/stable/howto/upgrade.html + "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", + # https://github.com/bluecurrent/HomeAssistantAPI + "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", + "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", + "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", + # https://github.com/graphql-python/gql + "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", + # -- unmaintained projects, last release about 2+ years - # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", - # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01 - "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder", # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", - # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) @@ -688,8 +661,6 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", - # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 - "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 @@ -701,8 +672,6 @@ filterwarnings = [ "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", - # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 From 3489ea30ddd7ece04553d40e874e81b67fc8bffa Mon Sep 17 00:00:00 2001 From: Dionisis Toulatos <47108480+dionisis2014@users.noreply.github.com> Date: Sat, 12 Apr 2025 20:30:06 +0300 Subject: [PATCH 027/116] Fix MQTT device discovery when using node_id (#142784) * Fix device discovery when using node_id * tests --------- Co-authored-by: jbouwh Co-authored-by: Jan Bouwhuis --- homeassistant/components/mqtt/discovery.py | 2 +- tests/components/mqtt/test_discovery.py | 172 ++++++++++++++++++++- 2 files changed, 166 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a527e712615..4ebdbbb6236 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -254,7 +254,7 @@ def _generate_device_config( comp_config = config[CONF_COMPONENTS] for platform, discover_id in mqtt_data.discovery_already_discovered: ids = discover_id.split(" ") - component_node_id = ids.pop(0) + component_node_id = f"{ids.pop(1)} {ids.pop(0)}" if len(ids) > 2 else ids.pop(0) component_object_id = " ".join(ids) if not ids: continue diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index ee33cbcbaa1..ee559ef4235 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -388,23 +388,181 @@ async def test_only_valid_components( assert not mock_dispatcher_send.called -async def test_correct_config_discovery( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +@pytest.mark.parametrize( + ("discovery_topic", "discovery_hash"), + [ + ("homeassistant/binary_sensor/bla/config", ("binary_sensor", "bla")), + ("homeassistant/binary_sensor/node/bla/config", ("binary_sensor", "node bla")), + ], + ids=["without_node", "with_node"], +) +async def test_correct_config_discovery_component( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + discovery_topic: str, + discovery_hash: tuple[str, str], ) -> None: """Test sending in correct JSON.""" await mqtt_mock_entry() + config_init = { + "name": "Beer", + "state_topic": "test-topic", + "unique_id": "bla001", + "device": {"identifiers": "0AFFD2", "name": "test_device1"}, + "o": {"name": "foobar"}, + } async_fire_mqtt_message( hass, - "homeassistant/binary_sensor/bla/config", - '{ "name": "Beer", "state_topic": "test-topic" }', + discovery_topic, + json.dumps(config_init), ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.test_device1_beer") assert state is not None - assert state.name == "Beer" - assert ("binary_sensor", "bla") in hass.data["mqtt"].discovery_already_discovered + assert state.name == "test_device1 Beer" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device1" + + # Update the device and component + config_update = { + "name": "Milk", + "state_topic": "test-topic", + "unique_id": "bla001", + "device": {"identifiers": "0AFFD2", "name": "test_device2"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_update), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device2 Milk" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device2" + + # Remove the device and component + async_fire_mqtt_message( + hass, + discovery_topic, + "", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is None + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is None + + +@pytest.mark.parametrize( + ("discovery_topic", "discovery_hash"), + [ + ("homeassistant/device/some_id/config", ("binary_sensor", "some_id bla")), + ( + "homeassistant/device/node_id/some_id/config", + ("binary_sensor", "some_id node_id bla"), + ), + ], + ids=["without_node", "with_node"], +) +async def test_correct_config_discovery_device( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + discovery_topic: str, + discovery_hash: tuple[str, str], +) -> None: + """Test sending in correct JSON.""" + await mqtt_mock_entry() + config_init = { + "cmps": { + "bla": { + "platform": "binary_sensor", + "name": "Beer", + "state_topic": "test-topic", + "unique_id": "bla001", + }, + }, + "device": {"identifiers": "0AFFD2", "name": "test_device1"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_init), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device1 Beer" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device1" + + # Update the device and component + config_update = { + "cmps": { + "bla": { + "platform": "binary_sensor", + "name": "Milk", + "state_topic": "test-topic", + "unique_id": "bla001", + }, + }, + "device": {"identifiers": "0AFFD2", "name": "test_device2"}, + "o": {"name": "foobar"}, + } + async_fire_mqtt_message( + hass, + discovery_topic, + json.dumps(config_update), + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is not None + assert state.name == "test_device2 Milk" + assert discovery_hash in hass.data["mqtt"].discovery_already_discovered + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is not None + assert device_entry.name == "test_device2" + + # Remove the device and component + async_fire_mqtt_message( + hass, + discovery_topic, + "", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_device1_beer") + + assert state is None + + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry is None @pytest.mark.parametrize( From f13bdd0da43be09ebfec90304c019440254d268e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Apr 2025 07:47:02 -1000 Subject: [PATCH 028/116] Add support for passing though `description_placeholders` to `_abort_if_unique_id_configured` (#142779) --- homeassistant/config_entries.py | 3 ++- tests/test_config_entries.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3064fdd54bb..c58a33ad68d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2913,6 +2913,7 @@ class ConfigFlow(ConfigEntryBaseFlow): reload_on_update: bool = True, *, error: str = "already_configured", + description_placeholders: Mapping[str, str] | None = None, ) -> None: """Abort if the unique ID is already configured. @@ -2953,7 +2954,7 @@ class ConfigFlow(ConfigEntryBaseFlow): return if should_reload: self.hass.config_entries.async_schedule_reload(entry.entry_id) - raise data_entry_flow.AbortFlow(error) + raise data_entry_flow.AbortFlow(error, description_placeholders) async def async_set_unique_id( self, unique_id: str | None = None, *, raise_on_progress: bool = True diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 13ecd855624..ba599c88518 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3259,7 +3259,9 @@ async def test_unique_id_update_existing_entry_without_reload( """Test user step.""" await self.async_set_unique_id("mock-unique-id") self._abort_if_unique_id_configured( - updates={"host": "1.1.1.1"}, reload_on_update=False + updates={"host": "1.1.1.1"}, + reload_on_update=False, + description_placeholders={"title": "Other device"}, ) with ( @@ -3275,6 +3277,7 @@ async def test_unique_id_update_existing_entry_without_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 0 @@ -3309,7 +3312,9 @@ async def test_unique_id_update_existing_entry_with_reload( """Test user step.""" await self.async_set_unique_id("mock-unique-id") await self._abort_if_unique_id_configured( - updates=updates, reload_on_update=True + updates=updates, + reload_on_update=True, + description_placeholders={"title": "Other device"}, ) with ( @@ -3325,6 +3330,7 @@ async def test_unique_id_update_existing_entry_with_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "1.1.1.1" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 1 @@ -3345,6 +3351,7 @@ async def test_unique_id_update_existing_entry_with_reload( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + assert result["description_placeholders"]["title"] == "Other device" assert entry.data["host"] == "2.2.2.2" assert entry.data["additional"] == "data" assert len(async_reload.mock_calls) == 0 From 06d61558627f7454f489bb158b498bed158daf6b Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 12 Apr 2025 20:18:26 +0200 Subject: [PATCH 029/116] add support for quadruple button events for xiaomi-ble (#142760) * bump xiaomi-ble to 0.36.0 * fix ruff * fix ruff * revert dependency bump --- homeassistant/components/xiaomi_ble/const.py | 2 ++ .../components/xiaomi_ble/device_trigger.py | 15 +++++++++++++++ homeassistant/components/xiaomi_ble/strings.json | 2 ++ 3 files changed, 19 insertions(+) diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 8ea99cf1f84..aab443c67fa 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -37,6 +37,7 @@ LOCK_FINGERPRINT = "lock_fingerprint" MOTION_DEVICE: Final = "motion_device" DOUBLE_BUTTON: Final = "double_button" TRIPPLE_BUTTON: Final = "tripple_button" +QUADRUPLE_BUTTON: Final = "quadruple_button" REMOTE: Final = "remote" REMOTE_FAN: Final = "remote_fan" REMOTE_VENFAN: Final = "remote_ventilator_fan" @@ -48,6 +49,7 @@ BUTTON_PRESS_LONG: Final = "button_press_long" BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long" TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long" +QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "quadruple_button_press_double_long" class XiaomiBleEvent(TypedDict): diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 119424788db..3c5488a1e74 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -47,6 +47,8 @@ from .const import ( LOCK_FINGERPRINT, MOTION, MOTION_DEVICE, + QUADRUPLE_BUTTON, + QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG, REMOTE, REMOTE_BATHROOM, REMOTE_FAN, @@ -123,6 +125,12 @@ EVENT_TYPES = { DIMMER: ["dimmer"], DOUBLE_BUTTON: ["button_left", "button_right"], TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"], + QUADRUPLE_BUTTON: [ + "button_left", + "button_mid_left", + "button_mid_right", + "button_right", + ], ERROR: ["error"], FINGERPRINT: ["fingerprint"], LOCK: ["lock"], @@ -205,6 +213,11 @@ TRIGGER_MODEL_DATA = { event_types=EVENT_TYPES[TRIPPLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), + QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[QUADRUPLE_BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), ERROR: TriggerModelData( event_class=EVENT_CLASS_ERROR, event_types=EVENT_TYPES[ERROR], @@ -261,6 +274,8 @@ MODEL_DATA = { "XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG], + "KS1": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG], + "KS1BP": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG], "YLYK01YL": TRIGGER_MODEL_DATA[REMOTE], "YLYK01YL-FANRC": TRIGGER_MODEL_DATA[REMOTE_FAN], "YLYK01YL-VENFAN": TRIGGER_MODEL_DATA[REMOTE_VENFAN], diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index cdee3fc3838..06b49b8e86f 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -86,6 +86,8 @@ "trigger_type": { "button": "Button \"{subtype}\"", "button_left": "Button Left \"{subtype}\"", + "button_mid_left": "Button Mid Left \"{subtype}\"", + "button_mid_right": "Button Mid Right \"{subtype}\"", "button_middle": "Button Middle \"{subtype}\"", "button_right": "Button Right \"{subtype}\"", "button_on": "Button On \"{subtype}\"", From 6feb9d4b4e1645c60f549d6129239e86538bdf5e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 20:19:49 +0200 Subject: [PATCH 030/116] Add entity translations to Syncthru (#142774) * Add entity translations to Syncthru * Add entity translations to Syncthru * Fix --- .../components/syncthru/binary_sensor.py | 1 - homeassistant/components/syncthru/entity.py | 2 + homeassistant/components/syncthru/sensor.py | 21 +- .../components/syncthru/strings.json | 44 ++ .../snapshots/test_binary_sensor.ambr | 28 +- .../syncthru/snapshots/test_sensor.ambr | 395 +++++++++--------- 6 files changed, 272 insertions(+), 219 deletions(-) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 72157b9a22d..f68f33043ee 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -87,7 +87,6 @@ class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity): serial_number = coordinator.data.serial_number() assert serial_number is not None self._attr_unique_id = f"{serial_number}_{entity_description.key}" - self._attr_name = name @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py index fa3fbb0f2f4..0b5e6324953 100644 --- a/homeassistant/components/syncthru/entity.py +++ b/homeassistant/components/syncthru/entity.py @@ -10,6 +10,8 @@ from . import DOMAIN, SyncthruCoordinator class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): """Base class for Syncthru entities.""" + _attr_has_entity_name = True + def __init__(self, coordinator: SyncthruCoordinator) -> None: """Initialize the Syncthru entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 022d48b463f..77d1123e773 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -41,7 +41,7 @@ def get_toner_entity_description(color: str) -> SyncThruSensorDescription: """Get toner entity description for a specific color.""" return SyncThruSensorDescription( key=f"toner_{color}", - name=f"Toner {color}", + translation_key=f"toner_{color}", native_unit_of_measurement=PERCENTAGE, value_fn=lambda printer: printer.toner_status().get(color, {}).get("remaining"), extra_state_attributes_fn=lambda printer: printer.toner_status().get(color, {}), @@ -52,7 +52,7 @@ def get_drum_entity_description(color: str) -> SyncThruSensorDescription: """Get drum entity description for a specific color.""" return SyncThruSensorDescription( key=f"drum_{color}", - name=f"Drum {color}", + translation_key=f"drum_{color}", native_unit_of_measurement=PERCENTAGE, value_fn=lambda printer: printer.drum_status().get(color, {}).get("remaining"), extra_state_attributes_fn=lambda printer: printer.drum_status().get(color, {}), @@ -61,9 +61,16 @@ def get_drum_entity_description(color: str) -> SyncThruSensorDescription: def get_input_tray_entity_description(tray: str) -> SyncThruSensorDescription: """Get input tray entity description for a specific tray.""" + placeholders = {} + translation_key = f"tray_{tray}" + if "_" in tray: + _, identifier = tray.split("_") + placeholders["tray_number"] = identifier + translation_key = "tray" return SyncThruSensorDescription( key=f"tray_{tray}", - name=f"Tray {tray}", + translation_key=translation_key, + translation_placeholders=placeholders, value_fn=( lambda printer: printer.input_tray_status().get(tray, {}).get("newError") or "Ready" @@ -78,7 +85,8 @@ def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription: """Get output tray entity description for a specific tray.""" return SyncThruSensorDescription( key=f"output_tray_{tray}", - name=f"Output Tray {tray}", + translation_key="output_tray", + translation_placeholders={"tray_number": str(tray)}, value_fn=( lambda printer: printer.output_tray_status().get(tray, {}).get("status") or "Ready" @@ -94,12 +102,12 @@ def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription: SENSOR_TYPES: tuple[SyncThruSensorDescription, ...] = ( SyncThruSensorDescription( key="active_alerts", - name="Active Alerts", + translation_key="active_alerts", value_fn=lambda printer: printer.raw().get("GXI_ACTIVE_ALERT_TOTAL"), ), SyncThruSensorDescription( key="main", - name="", + name=None, value_fn=lambda printer: SYNCTHRU_STATE_HUMAN[printer.device_status()], extra_state_attributes_fn=lambda printer: { "display_text": printer.device_status_details(), @@ -153,7 +161,6 @@ class SyncThruSensor(SyncthruEntity, SensorEntity): super().__init__(coordinator) self.entity_description = entity_description self.syncthru = coordinator.data - self._attr_name = f"{name} {entity_description.name}".strip() serial_number = coordinator.data.serial_number() assert serial_number is not None self._attr_unique_id = f"{serial_number}_{entity_description.key}" diff --git a/homeassistant/components/syncthru/strings.json b/homeassistant/components/syncthru/strings.json index c4087bdee04..d78d51db86d 100644 --- a/homeassistant/components/syncthru/strings.json +++ b/homeassistant/components/syncthru/strings.json @@ -23,5 +23,49 @@ } } } + }, + "entity": { + "sensor": { + "toner_black": { + "name": "Black toner level" + }, + "toner_cyan": { + "name": "Cyan toner level" + }, + "toner_magenta": { + "name": "Magenta toner level" + }, + "toner_yellow": { + "name": "Yellow toner level" + }, + "drum_black": { + "name": "Black drum level" + }, + "drum_cyan": { + "name": "Cyan drum level" + }, + "drum_magenta": { + "name": "Magenta drum level" + }, + "drum_yellow": { + "name": "Yellow drum level" + }, + "tray_mp": { + "name": "Multi-purpose tray" + }, + "tray_manual": { + "name": "Manual feed tray" + }, + "tray": { + "name": "Input tray {tray_number}" + }, + "output_tray": { + "name": "Output tray {tray_number}" + }, + "active_alerts": { + "name": "Active alerts", + "unit_of_measurement": "alerts" + } + } } } diff --git a/tests/components/syncthru/snapshots/test_binary_sensor.ambr b/tests/components/syncthru/snapshots/test_binary_sensor.ambr index 82b62394a63..4f8809fd984 100644 --- a/tests/components/syncthru/snapshots/test_binary_sensor.ambr +++ b/tests/components/syncthru/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[binary_sensor.my_printer-entry] +# name: test_all_entities[binary_sensor.sec84251907c415_connectivity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.my_printer', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.sec84251907c415_connectivity', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'My Printer', + 'original_name': 'Connectivity', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, @@ -33,21 +33,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.my_printer-state] +# name: test_all_entities[binary_sensor.sec84251907c415_connectivity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'My Printer', + 'friendly_name': 'SEC84251907C415 Connectivity', }), 'context': , - 'entity_id': 'binary_sensor.my_printer', + 'entity_id': 'binary_sensor.sec84251907c415_connectivity', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_all_entities[binary_sensor.my_printer_2-entry] +# name: test_all_entities[binary_sensor.sec84251907c415_problem-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,8 +60,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.my_printer_2', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.sec84251907c415_problem', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -72,7 +72,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'My Printer', + 'original_name': 'Problem', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, @@ -81,14 +81,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[binary_sensor.my_printer_2-state] +# name: test_all_entities[binary_sensor.sec84251907c415_problem-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'My Printer', + 'friendly_name': 'SEC84251907C415 Problem', }), 'context': , - 'entity_id': 'binary_sensor.my_printer_2', + 'entity_id': 'binary_sensor.sec84251907c415_problem', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/syncthru/snapshots/test_sensor.ambr b/tests/components/syncthru/snapshots/test_sensor.ambr index 50d892b5343..87e96a5cc53 100644 --- a/tests/components/syncthru/snapshots/test_sensor.ambr +++ b/tests/components/syncthru/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.my_printer-entry] +# name: test_all_entities[sensor.sec84251907c415-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,8 +12,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer', + 'original_name': None, 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, @@ -33,22 +33,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.my_printer-state] +# name: test_all_entities[sensor.sec84251907c415-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'display_text': '', - 'friendly_name': 'My Printer', + 'friendly_name': 'SEC84251907C415', 'icon': 'mdi:printer', }), 'context': , - 'entity_id': 'sensor.my_printer', + 'entity_id': 'sensor.sec84251907c415', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'warning', }) # --- -# name: test_all_entities[sensor.my_printer_active_alerts-entry] +# name: test_all_entities[sensor.sec84251907c415_active_alerts-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -61,8 +61,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer_active_alerts', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415_active_alerts', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -73,30 +73,31 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Active Alerts', + 'original_name': 'Active alerts', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_alerts', 'unique_id': '08HRB8GJ3F019DD_active_alerts', - 'unit_of_measurement': None, + 'unit_of_measurement': 'alerts', }) # --- -# name: test_all_entities[sensor.my_printer_active_alerts-state] +# name: test_all_entities[sensor.sec84251907c415_active_alerts-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'My Printer Active Alerts', + 'friendly_name': 'SEC84251907C415 Active alerts', 'icon': 'mdi:printer', + 'unit_of_measurement': 'alerts', }), 'context': , - 'entity_id': 'sensor.my_printer_active_alerts', + 'entity_id': 'sensor.sec84251907c415_active_alerts', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2', }) # --- -# name: test_all_entities[sensor.my_printer_output_tray_1-entry] +# name: test_all_entities[sensor.sec84251907c415_black_toner_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -109,8 +110,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer_output_tray_1', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415_black_toner_level', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -121,71 +122,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Output Tray 1', + 'original_name': 'Black toner level', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '08HRB8GJ3F019DD_output_tray_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.my_printer_output_tray_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'capacity': 50, - 'friendly_name': 'My Printer Output Tray 1', - 'icon': 'mdi:printer', - 'name': 1, - 'status': '', - }), - 'context': , - 'entity_id': 'sensor.my_printer_output_tray_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Ready', - }) -# --- -# name: test_all_entities[sensor.my_printer_toner_black-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.my_printer_toner_black', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Toner black', - 'platform': 'syncthru', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'toner_black', 'unique_id': '08HRB8GJ3F019DD_toner_black', 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.my_printer_toner_black-state] +# name: test_all_entities[sensor.sec84251907c415_black_toner_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'cnt': 1176, - 'friendly_name': 'My Printer Toner black', + 'friendly_name': 'SEC84251907C415 Black toner level', 'icon': 'mdi:printer', 'newError': 'C1-5110', 'opt': 1, @@ -193,14 +143,14 @@ 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.my_printer_toner_black', + 'entity_id': 'sensor.sec84251907c415_black_toner_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '8', }) # --- -# name: test_all_entities[sensor.my_printer_toner_cyan-entry] +# name: test_all_entities[sensor.sec84251907c415_cyan_toner_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -213,8 +163,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer_toner_cyan', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -225,20 +175,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Toner cyan', + 'original_name': 'Cyan toner level', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'toner_cyan', 'unique_id': '08HRB8GJ3F019DD_toner_cyan', 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[sensor.my_printer_toner_cyan-state] +# name: test_all_entities[sensor.sec84251907c415_cyan_toner_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'cnt': 25, - 'friendly_name': 'My Printer Toner cyan', + 'friendly_name': 'SEC84251907C415 Cyan toner level', 'icon': 'mdi:printer', 'newError': '', 'opt': 1, @@ -246,14 +196,14 @@ 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.my_printer_toner_cyan', + 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '98', }) # --- -# name: test_all_entities[sensor.my_printer_toner_magenta-entry] +# name: test_all_entities[sensor.sec84251907c415_input_tray_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -266,8 +216,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.my_printer_toner_magenta', - 'has_entity_name': False, + 'entity_id': 'sensor.sec84251907c415_input_tray_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -278,126 +228,20 @@ }), 'original_device_class': None, 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Toner magenta', + 'original_name': 'Input tray 1', 'platform': 'syncthru', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '08HRB8GJ3F019DD_toner_magenta', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[sensor.my_printer_toner_magenta-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'cnt': 25, - 'friendly_name': 'My Printer Toner magenta', - 'icon': 'mdi:printer', - 'newError': '', - 'opt': 1, - 'remaining': 98, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.my_printer_toner_magenta', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '98', - }) -# --- -# name: test_all_entities[sensor.my_printer_toner_yellow-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.my_printer_toner_yellow', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Toner yellow', - 'platform': 'syncthru', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '08HRB8GJ3F019DD_toner_yellow', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[sensor.my_printer_toner_yellow-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'cnt': 27, - 'friendly_name': 'My Printer Toner yellow', - 'icon': 'mdi:printer', - 'newError': '', - 'opt': 1, - 'remaining': 97, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.my_printer_toner_yellow', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '97', - }) -# --- -# name: test_all_entities[sensor.my_printer_tray_tray_1-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.my_printer_tray_tray_1', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:printer', - 'original_name': 'My Printer Tray tray_1', - 'platform': 'syncthru', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'tray', 'unique_id': '08HRB8GJ3F019DD_tray_tray_1', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[sensor.my_printer_tray_tray_1-state] +# name: test_all_entities[sensor.sec84251907c415_input_tray_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'capa': 150, - 'friendly_name': 'My Printer Tray tray_1', + 'friendly_name': 'SEC84251907C415 Input tray 1', 'icon': 'mdi:printer', 'newError': '', 'opt': 1, @@ -408,10 +252,167 @@ 'paper_type2': 0, }), 'context': , - 'entity_id': 'sensor.my_printer_tray_tray_1', + 'entity_id': 'sensor.sec84251907c415_input_tray_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'Ready', }) # --- +# name: test_all_entities[sensor.sec84251907c415_magenta_toner_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Magenta toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_magenta', + 'unique_id': '08HRB8GJ3F019DD_toner_magenta', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_magenta_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'SEC84251907C415 Magenta toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_output_tray_1-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.sec84251907c415_output_tray_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Output tray 1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'output_tray', + 'unique_id': '08HRB8GJ3F019DD_output_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_output_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capacity': 50, + 'friendly_name': 'SEC84251907C415 Output tray 1', + 'icon': 'mdi:printer', + 'name': 1, + 'status': '', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_output_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_yellow_toner_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'Yellow toner level', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'toner_yellow', + 'unique_id': '08HRB8GJ3F019DD_toner_yellow', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sec84251907c415_yellow_toner_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 27, + 'friendly_name': 'SEC84251907C415 Yellow toner level', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 97, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97', + }) +# --- From ebe71a1a38f72b56db660c79fbd55f922db73763 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 20:22:12 +0200 Subject: [PATCH 031/116] Add diagnostics support to Syncthru (#142776) --- .../components/syncthru/diagnostics.py | 18 ++ .../syncthru/snapshots/test_diagnostics.ambr | 193 ++++++++++++++++++ tests/components/syncthru/test_diagnostics.py | 29 +++ 3 files changed, 240 insertions(+) create mode 100644 homeassistant/components/syncthru/diagnostics.py create mode 100644 tests/components/syncthru/snapshots/test_diagnostics.ambr create mode 100644 tests/components/syncthru/test_diagnostics.py diff --git a/homeassistant/components/syncthru/diagnostics.py b/homeassistant/components/syncthru/diagnostics.py new file mode 100644 index 00000000000..5ff860ed41e --- /dev/null +++ b/homeassistant/components/syncthru/diagnostics.py @@ -0,0 +1,18 @@ +"""Diagnostics support for Syncthru.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return hass.data[DOMAIN][entry.entry_id].data.raw() diff --git a/tests/components/syncthru/snapshots/test_diagnostics.ambr b/tests/components/syncthru/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..9b22561a2d6 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_diagnostics.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'GXI_A3_SUPPORT': 0, + 'GXI_ACTIVE_ALERT_TOTAL': 2, + 'GXI_ADMIN_WUI_HAS_DEFAULT_PASS': 0, + 'GXI_IMAGING_BLACK_VALID': 1, + 'GXI_IMAGING_COLOR_VALID': 1, + 'GXI_IMAGING_CYAN_VALID': 1, + 'GXI_IMAGING_MAGENTA_VALID': 1, + 'GXI_IMAGING_YELLOW_VALID': 1, + 'GXI_INSTALL_OPTION_MULTIBIN': 0, + 'GXI_INTRAY_MANUALFEEDING_TRAY_SUPPORT': 0, + 'GXI_SUPPORT_COLOR': 1, + 'GXI_SUPPORT_MULTI_PASS': 1, + 'GXI_SUPPORT_PAPER_LEVEL': 0, + 'GXI_SUPPORT_PAPER_SETTING': 1, + 'GXI_SWS_ADMIN_USE_AAA': 0, + 'GXI_SYS_LUI_SUPPORT': 0, + 'GXI_TONER_BLACK_VALID': 1, + 'GXI_TONER_CYAN_VALID': 1, + 'GXI_TONER_MAGENTA_VALID': 1, + 'GXI_TONER_YELLOW_VALID': 1, + 'GXI_TRAY2_MANDATORY_SUPPORT': 0, + 'capability': dict({ + 'hdd': dict({ + 'capa': 40, + 'opt': 2, + }), + 'ram': dict({ + 'capa': 65536, + 'opt': 65536, + }), + 'scanner': dict({ + 'capa': 0, + 'opt': 0, + }), + }), + 'drum_black': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 44, + }), + 'drum_color': dict({ + 'newError': '', + 'opt': 1, + 'remaining': 44, + }), + 'drum_cyan': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'drum_magenta': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'drum_yellow': dict({ + 'newError': '', + 'opt': 0, + 'remaining': 100, + }), + 'identity': dict({ + 'admin_email': '', + 'admin_name': '', + 'admin_phone': '', + 'customer_support': '', + 'device_name': 'Samsung C430W', + 'host_name': 'SEC84251907C415', + 'ip_addr': '192.168.0.251', + 'ipv6_link_addr': '', + 'location': 'Living room', + 'mac_addr': '84:25:19:07:C4:15', + 'model_name': 'C430W', + 'serial_num': '08HRB8GJ3F019DD', + }), + 'manual': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'mp': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'multibin': list([ + 0, + ]), + 'options': dict({ + 'hdd': 0, + 'wlan': 1, + }), + 'outputTray': list([ + list([ + 1, + 50, + '', + ]), + ]), + 'status': dict({ + 'hrDeviceStatus': 3, + 'status1': '', + 'status2': '', + 'status3': '', + 'status4': '', + }), + 'toner_black': dict({ + 'cnt': 1176, + 'newError': 'C1-5110', + 'opt': 1, + 'remaining': 8, + }), + 'toner_cyan': dict({ + 'cnt': 25, + 'newError': '', + 'opt': 1, + 'remaining': 98, + }), + 'toner_magenta': dict({ + 'cnt': 25, + 'newError': '', + 'opt': 1, + 'remaining': 98, + }), + 'toner_yellow': dict({ + 'cnt': 27, + 'newError': '', + 'opt': 1, + 'remaining': 97, + }), + 'tray1': dict({ + 'capa': 150, + 'newError': '', + 'opt': 1, + 'paper_level': 0, + 'paper_size1': 4, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray2': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray3': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray4': dict({ + 'capa': 0, + 'newError': '', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'tray5': dict({ + 'capa': 0, + 'newError': '0', + 'opt': 0, + 'paper_level': 0, + 'paper_size1': 0, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + }) +# --- diff --git a/tests/components/syncthru/test_diagnostics.py b/tests/components/syncthru/test_diagnostics.py new file mode 100644 index 00000000000..f5988936328 --- /dev/null +++ b/tests/components/syncthru/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Syncthru integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From b95701779988befa7bd69e8769bdd5d0b8e26c3e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 20:50:37 +0200 Subject: [PATCH 032/116] Clean up Syncthru unique id (#142778) --- .../components/syncthru/binary_sensor.py | 19 +------------- homeassistant/components/syncthru/entity.py | 7 +++++- homeassistant/components/syncthru/sensor.py | 25 +++++-------------- 3 files changed, 13 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index f68f33043ee..45a3e263465 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -62,11 +61,8 @@ async def async_setup_entry( coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] - name: str = config_entry.data[CONF_NAME] - async_add_entities( - SyncThruBinarySensor(coordinator, name, description) - for description in BINARY_SENSORS + SyncThruBinarySensor(coordinator, description) for description in BINARY_SENSORS ) @@ -75,19 +71,6 @@ class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity): entity_description: SyncThruBinarySensorDescription - def __init__( - self, - coordinator: SyncthruCoordinator, - name: str, - entity_description: SyncThruBinarySensorDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = entity_description - serial_number = coordinator.data.serial_number() - assert serial_number is not None - self._attr_unique_id = f"{serial_number}_{entity_description.key}" - @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py index 0b5e6324953..a2feafbc495 100644 --- a/homeassistant/components/syncthru/entity.py +++ b/homeassistant/components/syncthru/entity.py @@ -2,6 +2,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, SyncthruCoordinator @@ -12,11 +13,15 @@ class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: SyncthruCoordinator) -> None: + def __init__( + self, coordinator: SyncthruCoordinator, entity_description: EntityDescription + ) -> None: """Initialize the Syncthru entity.""" super().__init__(coordinator) + self.entity_description = entity_description serial_number = coordinator.syncthru.serial_number() assert serial_number is not None + self._attr_unique_id = f"{serial_number}_{entity_description.key}" connections = set() if mac := coordinator.syncthru.raw().get("identity", {}).get("mac_addr"): connections.add((dr.CONNECTION_NETWORK_MAC, mac)) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 77d1123e773..f3fb9d6689e 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -10,7 +10,7 @@ from pysyncthru import SyncThru, SyncthruState from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, PERCENTAGE +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -131,7 +131,6 @@ async def async_setup_entry( supp_tray = printer.input_tray_status(filter_supported=True) supp_output_tray = printer.output_tray_status() - name: str = config_entry.data[CONF_NAME] entities: list[SyncThruSensorDescription] = [ get_toner_entity_description(color) for color in supp_toner ] @@ -140,7 +139,7 @@ async def async_setup_entry( entities.extend(get_output_tray_entity_description(key) for key in supp_output_tray) async_add_entities( - SyncThruSensor(coordinator, name, description) + SyncThruSensor(coordinator, description) for description in SENSOR_TYPES + tuple(entities) ) @@ -151,28 +150,16 @@ class SyncThruSensor(SyncthruEntity, SensorEntity): _attr_icon = "mdi:printer" entity_description: SyncThruSensorDescription - def __init__( - self, - coordinator: SyncthruCoordinator, - name: str, - entity_description: SyncThruSensorDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = entity_description - self.syncthru = coordinator.data - serial_number = coordinator.data.serial_number() - assert serial_number is not None - self._attr_unique_id = f"{serial_number}_{entity_description.key}" - @property def native_value(self) -> str | int | None: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.syncthru) + return self.entity_description.value_fn(self.coordinator.data) @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if self.entity_description.extra_state_attributes_fn: - return self.entity_description.extra_state_attributes_fn(self.syncthru) + return self.entity_description.extra_state_attributes_fn( + self.coordinator.data + ) return None From cba0cf060962d223f1e5b86ecd9a1222f863985e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 12 Apr 2025 20:59:59 +0200 Subject: [PATCH 033/116] Migrate Syncthru to runtime data (#142775) --- homeassistant/components/syncthru/__init__.py | 14 +++++--------- homeassistant/components/syncthru/binary_sensor.py | 8 +++----- homeassistant/components/syncthru/coordinator.py | 4 +++- homeassistant/components/syncthru/diagnostics.py | 7 +++---- homeassistant/components/syncthru/entity.py | 3 ++- homeassistant/components/syncthru/sensor.py | 8 +++----- tests/components/syncthru/conftest.py | 2 +- 7 files changed, 20 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 016d0de7257..f514f538821 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -4,22 +4,20 @@ from __future__ import annotations from pysyncthru import SyncThruAPINotSupported -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import SyncthruCoordinator +from .coordinator import SyncThruConfigEntry, SyncthruCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SyncThruConfigEntry) -> bool: """Set up config entry.""" coordinator = SyncthruCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator if isinstance(coordinator.last_exception, SyncThruAPINotSupported): # this means that the printer does not support the syncthru JSON API # and the config should simply be discarded @@ -29,8 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SyncThruConfigEntry) -> bool: """Unload the config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN].pop(entry.entry_id, None) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 45a3e263465..56edff38680 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -12,12 +12,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SyncthruCoordinator -from .const import DOMAIN +from .coordinator import SyncThruConfigEntry from .entity import SyncthruEntity SYNCTHRU_STATE_PROBLEM = { @@ -54,12 +52,12 @@ BINARY_SENSORS: tuple[SyncThruBinarySensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncThruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( SyncThruBinarySensor(coordinator, description) for description in BINARY_SENSORS diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py index 8bb10e8c861..0b96b354436 100644 --- a/homeassistant/components/syncthru/coordinator.py +++ b/homeassistant/components/syncthru/coordinator.py @@ -16,11 +16,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type SyncThruConfigEntry = ConfigEntry[SyncthruCoordinator] + class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): """Class to manage fetching Syncthru data.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: SyncThruConfigEntry) -> None: """Initialize the Syncthru coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/syncthru/diagnostics.py b/homeassistant/components/syncthru/diagnostics.py index 5ff860ed41e..169d354ef76 100644 --- a/homeassistant/components/syncthru/diagnostics.py +++ b/homeassistant/components/syncthru/diagnostics.py @@ -4,15 +4,14 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import SyncThruConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SyncThruConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return hass.data[DOMAIN][entry.entry_id].data.raw() + return entry.runtime_data.data.raw() diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py index a2feafbc495..3f1aecbf0d4 100644 --- a/homeassistant/components/syncthru/entity.py +++ b/homeassistant/components/syncthru/entity.py @@ -5,7 +5,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, SyncthruCoordinator +from .const import DOMAIN +from .coordinator import SyncthruCoordinator class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]): diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index f3fb9d6689e..7896b275f45 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -9,13 +9,11 @@ from typing import Any, cast from pysyncthru import SyncThru, SyncthruState from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SyncthruCoordinator -from .const import DOMAIN +from .coordinator import SyncThruConfigEntry from .entity import SyncthruEntity SYNCTHRU_STATE_HUMAN = { @@ -118,12 +116,12 @@ SENSOR_TYPES: tuple[SyncThruSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SyncThruConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" - coordinator: SyncthruCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data printer = coordinator.data supp_toner = printer.toner_status(filter_supported=True) diff --git a/tests/components/syncthru/conftest.py b/tests/components/syncthru/conftest.py index 1142726d04e..61b91d815a2 100644 --- a/tests/components/syncthru/conftest.py +++ b/tests/components/syncthru/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from pysyncthru import SyncthruState import pytest -from homeassistant.components.syncthru import DOMAIN +from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from tests.common import MockConfigEntry, load_json_object_fixture From 67c0af4c57091b35bcbfdd3179d0f57b0d847828 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 12 Apr 2025 21:04:05 +0200 Subject: [PATCH 034/116] Fix spelling of "off-peak", add common state for "Normal" in `plugwise` (#142682) --- homeassistant/components/plugwise/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 96f5366bb2a..344cee66d68 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -19,7 +19,7 @@ "host": "[%key:common::config_flow::data::ip%]", "password": "Smile ID", "port": "[%key:common::config_flow::data::port%]", - "username": "Smile Username" + "username": "Smile username" }, "data_description": { "password": "The Smile ID printed on the label on the back of your Adam, Smile-T, or P1.", @@ -122,7 +122,7 @@ "name": "Gateway mode", "state": { "away": "Pause", - "full": "Normal", + "full": "[%key:common::state::normal%]", "vacation": "Vacation" } }, @@ -184,7 +184,7 @@ "name": "Electricity consumed peak interval" }, "electricity_consumed_off_peak_interval": { - "name": "Electricity consumed off peak interval" + "name": "Electricity consumed off-peak interval" }, "electricity_produced_interval": { "name": "Electricity produced interval" @@ -193,19 +193,19 @@ "name": "Electricity produced peak interval" }, "electricity_produced_off_peak_interval": { - "name": "Electricity produced off peak interval" + "name": "Electricity produced off-peak interval" }, "electricity_consumed_point": { "name": "Electricity consumed point" }, "electricity_consumed_off_peak_point": { - "name": "Electricity consumed off peak point" + "name": "Electricity consumed off-peak point" }, "electricity_consumed_peak_point": { "name": "Electricity consumed peak point" }, "electricity_consumed_off_peak_cumulative": { - "name": "Electricity consumed off peak cumulative" + "name": "Electricity consumed off-peak cumulative" }, "electricity_consumed_peak_cumulative": { "name": "Electricity consumed peak cumulative" @@ -214,13 +214,13 @@ "name": "Electricity produced point" }, "electricity_produced_off_peak_point": { - "name": "Electricity produced off peak point" + "name": "Electricity produced off-peak point" }, "electricity_produced_peak_point": { "name": "Electricity produced peak point" }, "electricity_produced_off_peak_cumulative": { - "name": "Electricity produced off peak cumulative" + "name": "Electricity produced off-peak cumulative" }, "electricity_produced_peak_cumulative": { "name": "Electricity produced peak cumulative" From d6b4f1c95d2e574de55ca930bf52e110256241b5 Mon Sep 17 00:00:00 2001 From: Mathijs van de Nes Date: Sun, 13 Apr 2025 00:02:07 +0200 Subject: [PATCH 035/116] Ensure no ALPN is negotiated for SMTP (#142296) --- homeassistant/components/smtp/notify.py | 11 +++++++---- tests/components/smtp/test_notify.py | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index e86b22690a4..943be229ec3 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -38,7 +38,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from homeassistant.util.ssl import client_context +from homeassistant.util.ssl import create_client_context from .const import ( ATTR_HTML, @@ -86,6 +86,7 @@ def get_service( ) -> MailNotificationService | None: """Get the mail notification service.""" setup_reload_service(hass, DOMAIN, PLATFORMS) + ssl_context = create_client_context() if config[CONF_VERIFY_SSL] else None mail_service = MailNotificationService( config[CONF_SERVER], config[CONF_PORT], @@ -98,6 +99,7 @@ def get_service( config.get(CONF_SENDER_NAME), config[CONF_DEBUG], config[CONF_VERIFY_SSL], + ssl_context, ) if mail_service.connection_is_valid(): @@ -122,6 +124,7 @@ class MailNotificationService(BaseNotificationService): sender_name, debug, verify_ssl, + ssl_context, ): """Initialize the SMTP service.""" self._server = server @@ -136,23 +139,23 @@ class MailNotificationService(BaseNotificationService): self.debug = debug self._verify_ssl = verify_ssl self.tries = 2 + self._ssl_context = ssl_context def connect(self): """Connect/authenticate to SMTP Server.""" - ssl_context = client_context() if self._verify_ssl else None if self.encryption == "tls": mail = smtplib.SMTP_SSL( self._server, self._port, timeout=self._timeout, - context=ssl_context, + context=self._ssl_context, ) else: mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout) mail.set_debuglevel(self.debug) mail.ehlo_or_helo_if_needed() if self.encryption == "starttls": - mail.starttls(context=ssl_context) + mail.starttls(context=self._ssl_context) mail.ehlo() if self.username and self.password: mail.login(self.username, self.password) diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 901d7e547fe..0eb8fda09c5 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -14,6 +14,7 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component +from homeassistant.util.ssl import create_client_context from tests.common import get_fixture_path @@ -84,6 +85,7 @@ def message(): "Home Assistant", 0, True, + create_client_context(), ) From 505e09242d2971f63cc5f717c5be056a36acd153 Mon Sep 17 00:00:00 2001 From: zry98 Date: Sun, 13 Apr 2025 00:03:01 +0200 Subject: [PATCH 036/116] Bump xiaomi-ble to 0.37.0 (#142812) --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index ed534387114..a908d4747ad 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.36.0"] + "requirements": ["xiaomi-ble==0.37.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d6972d363fd..be71ed9360f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3094,7 +3094,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.36.0 +xiaomi-ble==0.37.0 # homeassistant.components.knx xknx==3.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9013ba24c57..fe02c481bad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2499,7 +2499,7 @@ wyoming==1.5.4 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.36.0 +xiaomi-ble==0.37.0 # homeassistant.components.knx xknx==3.6.0 From 03ccb529e45ae5089dc529e576bc0a34ce52c947 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Apr 2025 00:03:28 +0200 Subject: [PATCH 037/116] Update pillow to 11.2.1 (#142811) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test.txt | 1 - requirements_test_all.txt | 2 +- 15 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 2c672dd4abb..cb31c7d6314 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==11.1.0"] + "requirements": ["pydoods==1.0.2", "Pillow==11.2.1"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 35c5ae93b72..b5e25c08851 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==13.1.0", "Pillow==11.1.0"] + "requirements": ["av==13.1.0", "Pillow==11.2.1"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index e43377a3230..bc01476d509 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index b173a2c850b..6cab2c39c97 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==11.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 6925b9e2133..02074a18b61 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index cd3ee8eca42..e29e95abc62 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.2.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index cdc3b16f95d..6107a6057d1 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0"] + "requirements": ["Pillow==11.2.1"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index e1226fd344d..cee768b6ad0 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==11.1.0", "simplehound==0.3"] + "requirements": ["Pillow==11.2.1", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 81705e326f7..11e1b1d3485 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -11,6 +11,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==2.2.2", - "Pillow==11.1.0" + "Pillow==11.2.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 96efb888ab7..24fb7709782 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -49,7 +49,7 @@ numpy==2.2.2 orjson==3.10.16 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==11.1.0 +Pillow==11.2.1 propcache==0.3.1 psutil-home-assistant==0.0.1 PyJWT==2.10.1 diff --git a/pyproject.toml b/pyproject.toml index da076d1953d..8bb155a66ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ dependencies = [ "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==44.0.1", - "Pillow==11.1.0", + "Pillow==11.2.1", "propcache==0.3.1", "pyOpenSSL==25.0.0", "orjson==3.10.16", diff --git a/requirements.txt b/requirements.txt index a4b91259ef3..b771b7f38b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,7 +35,7 @@ mutagen==1.47.0 numpy==2.2.2 PyJWT==2.10.1 cryptography==44.0.1 -Pillow==11.1.0 +Pillow==11.2.1 propcache==0.3.1 pyOpenSSL==25.0.0 orjson==3.10.16 diff --git a/requirements_all.txt b/requirements_all.txt index be71ed9360f..66e7abac332 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,7 +33,7 @@ Mastodon.py==2.0.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.1.0 +Pillow==11.2.1 # homeassistant.components.plex PlexAPI==4.15.16 diff --git a/requirements_test.txt b/requirements_test.txt index 962a113e1a0..40de8fd7945 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -42,7 +42,6 @@ types-caldav==1.3.0.20241107 types-chardet==0.1.5 types-decorator==5.1.8.20250121 types-pexpect==4.9.0.20241208 -types-pillow==10.2.0.20240822 types-protobuf==5.29.1.20241207 types-psutil==6.1.0.20241221 types-pyserial==3.5.0.20250130 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe02c481bad..bced838dd90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,7 +33,7 @@ Mastodon.py==2.0.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.1.0 +Pillow==11.2.1 # homeassistant.components.plex PlexAPI==4.15.16 From d23c9f715e1f7772ec8ae176f3db4b9afa7db95d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Apr 2025 00:04:50 +0200 Subject: [PATCH 038/116] Update beautifulsoup4 to 4.13.3 (#142751) --- homeassistant/components/scrape/manifest.json | 2 +- pyproject.toml | 7 ++----- requirements_all.txt | 2 +- requirements_test.txt | 1 - requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 56b9470b4f7..28e08372d68 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.3", "lxml==5.3.0"] + "requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"] } diff --git a/pyproject.toml b/pyproject.toml index 8bb155a66ae..87d0cda9f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -527,8 +527,6 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", # -- fixed, waiting for release / update - # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 - "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 @@ -584,9 +582,6 @@ filterwarnings = [ "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # Wrong stacklevel - # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3 - "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", # New in aiohttp - v3.9.0 "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", # - SyntaxWarnings @@ -645,6 +640,8 @@ filterwarnings = [ # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", + # https://pypi.org/project/enocean/ - v0.50.1 (installed) -> v0.60.1 - 2021-06-18 + "ignore:It looks like you're using an HTML parser to parse an XML document:UserWarning:enocean.protocol.eep", # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) diff --git a/requirements_all.txt b/requirements_all.txt index 66e7abac332..18813e5e531 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -590,7 +590,7 @@ batinfo==0.4.2 # beacontools[scan]==2.1.0 # homeassistant.components.scrape -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.3 # homeassistant.components.beewi_smartclim # beewi-smartclim==0.0.10 diff --git a/requirements_test.txt b/requirements_test.txt index 40de8fd7945..7b4ab7a02c0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -37,7 +37,6 @@ tqdm==4.67.1 types-aiofiles==24.1.0.20241221 types-atomicwrites==1.4.5.1 types-croniter==5.0.1.20241205 -types-beautifulsoup4==4.12.0.20250204 types-caldav==1.3.0.20241107 types-chardet==0.1.5 types-decorator==5.1.8.20250121 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bced838dd90..76205936755 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -527,7 +527,7 @@ babel==2.15.0 base36==0.1.1 # homeassistant.components.scrape -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.17.2 From 6737c51fcab133c767b649bb69e6b12a267c8f25 Mon Sep 17 00:00:00 2001 From: Chase Mamatey Date: Sat, 12 Apr 2025 19:00:49 -0400 Subject: [PATCH 039/116] Fix duke_energy data retrieval to adhere to service start date (#136054) --- .../components/duke_energy/coordinator.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index a76168475c0..a70c94e6fee 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -179,22 +179,18 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): one = timedelta(days=1) if start_time is None: # Max 3 years of data - agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) - if agreement_date is None: - start = dt_util.now(tz) - timedelta(days=3 * 365) - else: - start = max( - agreement_date.replace(tzinfo=tz), - dt_util.now(tz) - timedelta(days=3 * 365), - ) + start = dt_util.now(tz) - timedelta(days=3 * 365) else: start = datetime.fromtimestamp(start_time, tz=tz) - lookback + agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) + if agreement_date is not None: + start = max(agreement_date.replace(tzinfo=tz), start) start = start.replace(hour=0, minute=0, second=0, microsecond=0) end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one _LOGGER.debug("Data lookup range: %s - %s", start, end) - start_step = end - lookback + start_step = max(end - lookback, start) end_step = end usage: dict[datetime, dict[str, float | int]] = {} while True: From 5eb25b2d4a698ddd30d791f1a77888914794cf5f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 13 Apr 2025 12:40:53 +0200 Subject: [PATCH 040/116] Use common states for "Low"/"Medium"/"High" in `sensibo` (#142118) --- homeassistant/components/sensibo/strings.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 0fbcda461c8..e7a440b4910 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -115,7 +115,7 @@ "sensitivity": { "name": "Pure sensitivity", "state": { - "n": "Normal", + "n": "[%key:common::state::normal%]", "s": "Sensitive" } }, @@ -140,10 +140,10 @@ "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "medium_low": "Medium low", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "Medium high", "strong": "Strong", "quiet": "Quiet" @@ -226,10 +226,10 @@ "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]" @@ -364,11 +364,11 @@ "state": { "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", - "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", - "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", - "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", + "high": "[%key:common::state::high%]", "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" } }, @@ -524,7 +524,7 @@ "selector": { "sensitivity": { "options": { - "normal": "[%key:component::sensibo::entity::sensor::sensitivity::state::n%]", + "normal": "[%key:common::state::normal%]", "sensitive": "[%key:component::sensibo::entity::sensor::sensitivity::state::s%]" } }, From 31c2d22912e953ba35b8e8814ff731c044a5b59b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 13 Apr 2025 21:55:16 +1000 Subject: [PATCH 041/116] Check Energy Live API works before creating the coordinator in Tessie (#142510) * Check live API works before creating the coordinator * Fix diag * Fix mypy on entity * is not None --- homeassistant/components/tessie/__init__.py | 24 ++++++++++++++++--- .../components/tessie/binary_sensor.py | 2 ++ .../components/tessie/coordinator.py | 12 +++++++++- .../components/tessie/diagnostics.py | 4 +++- homeassistant/components/tessie/entity.py | 2 +- homeassistant/components/tessie/models.py | 2 +- homeassistant/components/tessie/sensor.py | 9 +++++-- 7 files changed, 46 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index e247931e3ba..7fd2729ef03 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -6,7 +6,12 @@ import logging from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import Scope -from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.exceptions import ( + Forbidden, + InvalidToken, + SubscriptionRequired, + TeslaFleetError, +) from tesla_fleet_api.tessie import Tessie from tessie_api import get_state_of_all_vehicles @@ -124,12 +129,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo continue api = tessie.energySites.create(site_id) + + try: + live_status = (await api.live_status())["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise ConfigEntryNotReady(e.message) from e + energysites.append( TessieEnergyData( api=api, id=site_id, - live_coordinator=TessieEnergySiteLiveCoordinator( - hass, entry, api + live_coordinator=( + TessieEnergySiteLiveCoordinator( + hass, entry, api, live_status + ) + if isinstance(live_status, dict) + else None ), info_coordinator=TessieEnergySiteInfoCoordinator( hass, entry, api @@ -147,6 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo *( energysite.live_coordinator.async_config_entry_first_refresh() for energysite in energysites + if energysite.live_coordinator is not None ), *( energysite.info_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 515339c3da8..cdf3b0035fc 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -191,6 +191,7 @@ async def async_setup_entry( TessieEnergyLiveBinarySensorEntity(energy, description) for energy in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS + if energy.live_coordinator is not None ), ( TessieEnergyInfoBinarySensorEntity(vehicle, description) @@ -233,6 +234,7 @@ class TessieEnergyLiveBinarySensorEntity(TessieEnergyEntity, BinarySensorEntity) ) -> None: """Initialize the binary sensor.""" self.entity_description = description + assert data.live_coordinator is not None super().__init__(data, data.live_coordinator, description.key) def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 2382595b058..8b6fb639a64 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -102,7 +102,11 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: TessieConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySite + self, + hass: HomeAssistant, + config_entry: TessieConfigEntry, + api: EnergySite, + data: dict[str, Any], ) -> None: """Initialize Tessie Energy Site Live coordinator.""" super().__init__( @@ -114,6 +118,12 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.api = api + # Convert Wall Connectors from array to dict + data["wall_connectors"] = { + wc["din"]: wc for wc in (data.get("wall_connectors") or []) + } + self.data = data + async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Tessie API.""" diff --git a/homeassistant/components/tessie/diagnostics.py b/homeassistant/components/tessie/diagnostics.py index bd2db772b57..21fc208612d 100644 --- a/homeassistant/components/tessie/diagnostics.py +++ b/homeassistant/components/tessie/diagnostics.py @@ -41,7 +41,9 @@ async def async_get_config_entry_diagnostics( ] energysites = [ { - "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT), + "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT) + if x.live_coordinator + else None, "info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT), } for x in entry.runtime_data.energysites diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index a2b6d3c9761..fb49d02f42e 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -155,7 +155,7 @@ class TessieWallConnectorEntity(TessieBaseEntity): via_device=(DOMAIN, str(data.id)), serial_number=din.split("-")[-1], ) - + assert data.live_coordinator super().__init__(data.live_coordinator, key) @property diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index 03652782cfe..5330d2d0bf0 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -28,7 +28,7 @@ class TessieEnergyData: """Data for a Energy Site in the Tessie integration.""" api: EnergySite - live_coordinator: TessieEnergySiteLiveCoordinator + live_coordinator: TessieEnergySiteLiveCoordinator | None info_coordinator: TessieEnergySiteInfoCoordinator id: int device: DeviceInfo diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index e5b476057fa..52accb15575 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -396,12 +396,16 @@ async def async_setup_entry( TessieEnergyLiveSensorEntity(energysite, description) for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS - if description.key in energysite.live_coordinator.data - or description.key == "percentage_charged" + if energysite.live_coordinator is not None + and ( + description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" + ) ), ( # Add wall connectors TessieWallConnectorSensorEntity(energysite, din, description) for energysite in entry.runtime_data.energysites + if energysite.live_coordinator is not None for din in energysite.live_coordinator.data.get("wall_connectors", {}) for description in WALL_CONNECTOR_DESCRIPTIONS ), @@ -446,6 +450,7 @@ class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity): ) -> None: """Initialize the sensor.""" self.entity_description = description + assert data.live_coordinator is not None super().__init__(data, data.live_coordinator, description.key) def _async_update_attrs(self) -> None: From e370248c9eeb6b89b852c85614512fc091e8fa53 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 13 Apr 2025 15:30:47 +0200 Subject: [PATCH 042/116] Use typed ConfigEntry in UptimeRobot (#142846) --- homeassistant/components/uptimerobot/__init__.py | 9 +++++---- homeassistant/components/uptimerobot/binary_sensor.py | 5 ++--- homeassistant/components/uptimerobot/coordinator.py | 6 ++++-- homeassistant/components/uptimerobot/diagnostics.py | 5 ++--- homeassistant/components/uptimerobot/sensor.py | 5 ++--- homeassistant/components/uptimerobot/switch.py | 5 ++--- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index b8619b1fe39..7bf990489e6 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -4,17 +4,16 @@ from __future__ import annotations from pyuptimerobot import UptimeRobot -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) -> bool: """Set up UptimeRobot from a config entry.""" hass.data.setdefault(DOMAIN, {}) key: str = entry.data[CONF_API_KEY] @@ -37,7 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: UptimeRobotConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 73f9400c013..0ad39a5b2c0 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -7,18 +7,17 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot binary_sensors.""" diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index fbadc237965..2f6225fa498 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -17,16 +17,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER +type UptimeRobotConfigEntry = ConfigEntry[UptimeRobotDataUpdateCoordinator] + class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): """Data update coordinator for UptimeRobot.""" - config_entry: ConfigEntry + config_entry: UptimeRobotConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UptimeRobotConfigEntry, api: UptimeRobot, ) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py index 23c65373045..b159d6ddba9 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -6,16 +6,15 @@ from typing import Any from pyuptimerobot import UptimeRobotException -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator async def async_get_config_entry_diagnostics( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 724c3075a3b..1f1db8844e6 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -7,13 +7,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity SENSORS_INFO = { @@ -27,7 +26,7 @@ SENSORS_INFO = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot sensors.""" diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 31401ac7eb4..edd93d06e0b 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -11,18 +11,17 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API_ATTR_OK, DOMAIN, LOGGER -from .coordinator import UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UptimeRobotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot switches.""" From 6d78c961d976250221dbba726adcb860164b35f8 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 13 Apr 2025 16:10:52 +0200 Subject: [PATCH 043/116] Bump colorlog to 6.9.0 (#142616) --- homeassistant/scripts/check_config.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index ca3df5080b5..981f0a26926 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -29,7 +29,7 @@ from homeassistant.helpers.check_config import async_check_ha_config_file # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==6.8.2",) +REQUIREMENTS = ("colorlog==6.9.0",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { diff --git a/requirements_all.txt b/requirements_all.txt index 18813e5e531..682b84dfe98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -709,7 +709,7 @@ coinbase-advanced-py==1.2.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.8.2 +colorlog==6.9.0 # homeassistant.components.color_extractor colorthief==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76205936755..f9c3fd3410b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -612,7 +612,7 @@ coinbase-advanced-py==1.2.2 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.8.2 +colorlog==6.9.0 # homeassistant.components.color_extractor colorthief==0.2.1 From b25a0e22723392bf4c65e56a1495e3f82e543932 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 13 Apr 2025 18:57:00 +0200 Subject: [PATCH 044/116] Small cleanup for Vodafone Station (#142867) --- .../vodafone_station/config_flow.py | 2 -- .../vodafone_station/test_config_flow.py | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 6641f5f5711..c21796d4064 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -156,8 +156,6 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} - errors = {} - try: await validate_input(self.hass, user_input) except aiovodafone_exceptions.AlreadyLogged: diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 0648987eb27..7ab56f2e967 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -246,12 +246,14 @@ async def test_reconfigure_successful( # original entry assert mock_config_entry.data["host"] == "fake_host" + new_host = "192.168.100.60" + reconfigure_result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "host": "192.168.100.60", - "password": "fake_password", - "username": "fake_username", + user_input={ + CONF_HOST: new_host, + CONF_PASSWORD: "fake_password", + CONF_USERNAME: "fake_username", }, ) @@ -259,7 +261,7 @@ async def test_reconfigure_successful( assert reconfigure_result["reason"] == "reconfigure_successful" # changed entry - assert mock_config_entry.data["host"] == "192.168.100.60" + assert mock_config_entry.data["host"] == new_host @pytest.mark.parametrize( @@ -290,10 +292,10 @@ async def test_reconfigure_fails( reconfigure_result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "host": "192.168.100.60", - "password": "fake_password", - "username": "fake_username", + user_input={ + CONF_HOST: "192.168.100.60", + CONF_PASSWORD: "fake_password", + CONF_USERNAME: "fake_username", }, ) From 0b02b43b11645e06faaf05a10eb739357761c2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sun, 13 Apr 2025 21:09:41 +0200 Subject: [PATCH 045/116] Add integration for Miele (#142498) --- CODEOWNERS | 2 + homeassistant/components/miele/__init__.py | 70 ++++ homeassistant/components/miele/api.py | 27 ++ .../miele/application_credentials.py | 21 + homeassistant/components/miele/config_flow.py | 73 ++++ homeassistant/components/miele/const.py | 154 +++++++ homeassistant/components/miele/coordinator.py | 87 ++++ homeassistant/components/miele/entity.py | 56 +++ homeassistant/components/miele/manifest.json | 13 + .../components/miele/quality_scale.yaml | 76 ++++ homeassistant/components/miele/sensor.py | 211 ++++++++++ homeassistant/components/miele/strings.json | 154 +++++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 7 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/miele/__init__.py | 13 + tests/components/miele/conftest.py | 145 +++++++ tests/components/miele/const.py | 5 + .../components/miele/fixtures/3_devices.json | 359 +++++++++++++++++ .../miele/fixtures/action_freezer.json | 21 + .../miele/fixtures/action_fridge.json | 21 + .../fixtures/action_washing_machine.json | 15 + .../components/miele/snapshots/test_init.ambr | 34 ++ .../miele/snapshots/test_sensor.ambr | 375 ++++++++++++++++++ tests/components/miele/test_config_flow.py | 214 ++++++++++ tests/components/miele/test_init.py | 120 ++++++ tests/components/miele/test_sensor.py | 27 ++ 29 files changed, 2308 insertions(+) create mode 100644 homeassistant/components/miele/__init__.py create mode 100644 homeassistant/components/miele/api.py create mode 100644 homeassistant/components/miele/application_credentials.py create mode 100644 homeassistant/components/miele/config_flow.py create mode 100644 homeassistant/components/miele/const.py create mode 100644 homeassistant/components/miele/coordinator.py create mode 100644 homeassistant/components/miele/entity.py create mode 100644 homeassistant/components/miele/manifest.json create mode 100644 homeassistant/components/miele/quality_scale.yaml create mode 100644 homeassistant/components/miele/sensor.py create mode 100644 homeassistant/components/miele/strings.json create mode 100644 tests/components/miele/__init__.py create mode 100644 tests/components/miele/conftest.py create mode 100644 tests/components/miele/const.py create mode 100644 tests/components/miele/fixtures/3_devices.json create mode 100644 tests/components/miele/fixtures/action_freezer.json create mode 100644 tests/components/miele/fixtures/action_fridge.json create mode 100644 tests/components/miele/fixtures/action_washing_machine.json create mode 100644 tests/components/miele/snapshots/test_init.ambr create mode 100644 tests/components/miele/snapshots/test_sensor.ambr create mode 100644 tests/components/miele/test_config_flow.py create mode 100644 tests/components/miele/test_init.py create mode 100644 tests/components/miele/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1a1377f4d3f..fe1e60f5adc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -937,6 +937,8 @@ build.json @home-assistant/supervisor /tests/components/metoffice/ @MrHarcombe @avee87 /homeassistant/components/microbees/ @microBeesTech /tests/components/microbees/ @microBeesTech +/homeassistant/components/miele/ @astrandb +/tests/components/miele/ @astrandb /homeassistant/components/mikrotik/ @engrbm87 /tests/components/mikrotik/ @engrbm87 /homeassistant/components/mill/ @danielhiversen diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py new file mode 100644 index 00000000000..13247c42034 --- /dev/null +++ b/homeassistant/components/miele/__init__.py @@ -0,0 +1,70 @@ +"""The Miele integration.""" + +from __future__ import annotations + +from aiohttp import ClientError, ClientResponseError + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: + """Set up Miele from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="config_entry_auth_failed", + ) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err + except ClientError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="config_entry_not_ready", + ) from err + + # Setup MieleAPI and coordinator for data fetch + coordinator = MieleDataUpdateCoordinator(hass, auth) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + entry.async_create_background_task( + hass, + coordinator.api.listen_events( + data_callback=coordinator.callback_update_data, + actions_callback=coordinator.callback_update_actions, + ), + "pymiele event listener", + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/miele/api.py b/homeassistant/components/miele/api.py new file mode 100644 index 00000000000..632314f405c --- /dev/null +++ b/homeassistant/components/miele/api.py @@ -0,0 +1,27 @@ +"""API for Miele bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession +from pymiele import MIELE_API, AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Miele authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Miele auth.""" + super().__init__(websession, MIELE_API) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/miele/application_credentials.py b/homeassistant/components/miele/application_credentials.py new file mode 100644 index 00000000000..d40ef765ce0 --- /dev/null +++ b/homeassistant/components/miele/application_credentials.py @@ -0,0 +1,21 @@ +"""Application credentials platform for the Miele integration.""" + +from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "register_url": "https://www.miele.com/f/com/en/register_api.aspx", + } diff --git a/homeassistant/components/miele/config_flow.py b/homeassistant/components/miele/config_flow.py new file mode 100644 index 00000000000..d3c7dbba12b --- /dev/null +++ b/homeassistant/components/miele/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for Miele.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Miele OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + # "vg" is mandatory but the value doesn't seem to matter + return { + "vg": "sv-SE", + } + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + ) + + return await self.async_step_user() + + async def async_step_reconfigure( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """User initiated reconfiguration.""" + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create or update the config entry.""" + + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), data=data + ) + return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py new file mode 100644 index 00000000000..48bb724b888 --- /dev/null +++ b/homeassistant/components/miele/const.py @@ -0,0 +1,154 @@ +"""Constants for the Miele integration.""" + +from enum import IntEnum + +DOMAIN = "miele" +MANUFACTURER = "Miele" + +ACTIONS = "actions" +POWER_ON = "powerOn" +POWER_OFF = "powerOff" +PROCESS_ACTION = "processAction" + + +class MieleAppliance(IntEnum): + """Define appliance types.""" + + WASHING_MACHINE = 1 + TUMBLE_DRYER = 2 + WASHING_MACHINE_SEMI_PROFESSIONAL = 3 + TUMBLE_DRYER_SEMI_PROFESSIONAL = 4 + WASHING_MACHINE_PROFESSIONAL = 5 + DRYER_PROFESSIONAL = 6 + DISHWASHER = 7 + DISHWASHER_SEMI_PROFESSIONAL = 8 + DISHWASHER_PROFESSIONAL = 9 + OVEN = 12 + OVEN_MICROWAVE = 13 + HOB_HIGHLIGHT = 14 + STEAM_OVEN = 15 + MICROWAVE = 16 + COFFEE_SYSTEM = 17 + HOOD = 18 + FRIDGE = 19 + FREEZER = 20 + FRIDGE_FREEZER = 21 + ROBOT_VACUUM_CLEANER = 23 + WASHER_DRYER = 24 + DISH_WARMER = 25 + HOB_INDUCTION = 27 + STEAM_OVEN_COMBI = 31 + WINE_CABINET = 32 + WINE_CONDITIONING_UNIT = 33 + WINE_STORAGE_CONDITIONING_UNIT = 34 + STEAM_OVEN_MICRO = 45 + DIALOG_OVEN = 67 + WINE_CABINET_FREEZER = 68 + STEAM_OVEN_MK2 = 73 + HOB_INDUCT_EXTR = 74 + + +DEVICE_TYPE_TAGS = { + MieleAppliance.WASHING_MACHINE: "washing_machine", + MieleAppliance.TUMBLE_DRYER: "tumble_dryer", + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: "washing_machine", + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "tumble_dryer", + MieleAppliance.WASHING_MACHINE_PROFESSIONAL: "washing_machine", + MieleAppliance.DRYER_PROFESSIONAL: "tumble_dryer", + MieleAppliance.DISHWASHER: "dishwasher", + MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: "dishwasher", + MieleAppliance.DISHWASHER_PROFESSIONAL: "dishwasher", + MieleAppliance.OVEN: "oven", + MieleAppliance.OVEN_MICROWAVE: "oven_microwave", + MieleAppliance.HOB_HIGHLIGHT: "hob", + MieleAppliance.STEAM_OVEN: "steam_oven", + MieleAppliance.MICROWAVE: "microwave", + MieleAppliance.COFFEE_SYSTEM: "coffee_system", + MieleAppliance.HOOD: "hood", + MieleAppliance.FRIDGE: "refrigerator", + MieleAppliance.FREEZER: "freezer", + MieleAppliance.FRIDGE_FREEZER: "fridge_freezer", + MieleAppliance.ROBOT_VACUUM_CLEANER: "robot_vacuum_cleaner", + MieleAppliance.WASHER_DRYER: "washer_dryer", + MieleAppliance.DISH_WARMER: "warming_drawer", + MieleAppliance.HOB_INDUCTION: "hob", + MieleAppliance.STEAM_OVEN_COMBI: "steam_oven_combi", + MieleAppliance.WINE_CABINET: "wine_cabinet", + MieleAppliance.WINE_CONDITIONING_UNIT: "wine_conditioning_unit", + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "wine_unit", + MieleAppliance.STEAM_OVEN_MICRO: "steam_oven_micro", + MieleAppliance.DIALOG_OVEN: "dialog_oven", + MieleAppliance.WINE_CABINET_FREEZER: "wine_cabinet_freezer", + MieleAppliance.STEAM_OVEN_MK2: "steam_oven", + MieleAppliance.HOB_INDUCT_EXTR: "hob_extraction", +} + + +class StateStatus(IntEnum): + """Define appliance states.""" + + RESERVED = 0 + OFF = 1 + ON = 2 + PROGRAMMED = 3 + WAITING_TO_START = 4 + RUNNING = 5 + PAUSE = 6 + PROGRAM_ENDED = 7 + FAILURE = 8 + PROGRAM_INTERRUPTED = 9 + IDLE = 10 + RINSE_HOLD = 11 + SERVICE = 12 + SUPERFREEZING = 13 + SUPERCOOLING = 14 + SUPERHEATING = 15 + SUPERCOOLING_SUPERFREEZING = 146 + AUTOCLEANING = 147 + NOT_CONNECTED = 255 + + +STATE_STATUS_TAGS = { + StateStatus.OFF: "off", + StateStatus.ON: "on", + StateStatus.PROGRAMMED: "programmed", + StateStatus.WAITING_TO_START: "waiting_to_start", + StateStatus.RUNNING: "running", + StateStatus.PAUSE: "pause", + StateStatus.PROGRAM_ENDED: "program_ended", + StateStatus.FAILURE: "failure", + StateStatus.PROGRAM_INTERRUPTED: "program_interrupted", + StateStatus.IDLE: "idle", + StateStatus.RINSE_HOLD: "rinse_hold", + StateStatus.SERVICE: "service", + StateStatus.SUPERFREEZING: "superfreezing", + StateStatus.SUPERCOOLING: "supercooling", + StateStatus.SUPERHEATING: "superheating", + StateStatus.SUPERCOOLING_SUPERFREEZING: "supercooling_superfreezing", + StateStatus.AUTOCLEANING: "autocleaning", + StateStatus.NOT_CONNECTED: "not_connected", +} + + +class MieleActions(IntEnum): + """Define appliance actions.""" + + START = 1 + STOP = 2 + PAUSE = 3 + START_SUPERFREEZE = 4 + STOP_SUPERFREEZE = 5 + START_SUPERCOOL = 6 + STOP_SUPERCOOL = 7 + + +# Possible actions +PROCESS_ACTIONS = { + "start": MieleActions.START, + "stop": MieleActions.STOP, + "pause": MieleActions.PAUSE, + "start_superfreezing": MieleActions.START_SUPERFREEZE, + "stop_superfreezing": MieleActions.STOP_SUPERFREEZE, + "start_supercooling": MieleActions.START_SUPERCOOL, + "stop_supercooling": MieleActions.STOP_SUPERCOOL, +} diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py new file mode 100644 index 00000000000..8902f0f173a --- /dev/null +++ b/homeassistant/components/miele/coordinator.py @@ -0,0 +1,87 @@ +"""Coordinator module for Miele integration.""" + +from __future__ import annotations + +import asyncio.timeouts +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pymiele import MieleAction, MieleDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type MieleConfigEntry = ConfigEntry[MieleDataUpdateCoordinator] + + +@dataclass +class MieleCoordinatorData: + """Data class for storing coordinator data.""" + + devices: dict[str, MieleDevice] + actions: dict[str, MieleAction] + + +class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): + """Coordinator for Miele data.""" + + def __init__( + self, + hass: HomeAssistant, + api: AsyncConfigEntryAuth, + ) -> None: + """Initialize the Miele data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=120), + ) + self.api = api + + async def _async_update_data(self) -> MieleCoordinatorData: + """Fetch data from the Miele API.""" + async with asyncio.timeout(10): + # Get devices + devices_json = await self.api.get_devices() + devices = { + device_id: MieleDevice(device) + for device_id, device in devices_json.items() + } + actions = {} + for device_id in devices: + actions_json = await self.api.get_actions(device_id) + actions[device_id] = MieleAction(actions_json) + return MieleCoordinatorData(devices=devices, actions=actions) + + async def callback_update_data(self, devices_json: dict[str, dict]) -> None: + """Handle data update from the API.""" + devices = { + device_id: MieleDevice(device) for device_id, device in devices_json.items() + } + self.async_set_updated_data( + MieleCoordinatorData( + devices=devices, + actions=self.data.actions, + ) + ) + + async def callback_update_actions(self, actions_json: dict[str, dict]) -> None: + """Handle data update from the API.""" + actions = { + device_id: MieleAction(action) for device_id, action in actions_json.items() + } + self.async_set_updated_data( + MieleCoordinatorData( + devices=self.data.devices, + actions=actions, + ) + ) diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py new file mode 100644 index 00000000000..337f583cbff --- /dev/null +++ b/homeassistant/components/miele/entity.py @@ -0,0 +1,56 @@ +"""Entity base class for the Miele integration.""" + +from pymiele import MieleDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEVICE_TYPE_TAGS, DOMAIN, MANUFACTURER, MieleAppliance, StateStatus +from .coordinator import MieleDataUpdateCoordinator + + +class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): + """Base class for Miele entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device_id = device_id + self.entity_description = description + self._attr_unique_id = f"{device_id}-{description.key}" + + device = self.device + appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type)) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + serial_number=device_id, + name=appliance_type or device.tech_type, + translation_key=appliance_type, + manufacturer=MANUFACTURER, + model=device.tech_type, + hw_version=device.xkm_tech_type, + sw_version=device.xkm_release_version, + ) + + @property + def device(self) -> MieleDevice: + """Return the device object.""" + return self.coordinator.data.devices[self._device_id] + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + + return ( + super().available + and self._device_id in self.coordinator.data.devices + and (self.device.state_status is not StateStatus.NOT_CONNECTED) + ) diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json new file mode 100644 index 00000000000..414db320718 --- /dev/null +++ b/homeassistant/components/miele/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "miele", + "name": "Miele", + "codeowners": ["@astrandb"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/miele", + "iot_class": "cloud_push", + "loggers": ["pymiele"], + "quality_scale": "bronze", + "requirements": ["pymiele==0.3.4"], + "single_config_entry": true +} diff --git a/homeassistant/components/miele/quality_scale.yaml b/homeassistant/components/miele/quality_scale.yaml new file mode 100644 index 00000000000..e9d229c6a1b --- /dev/null +++ b/homeassistant/components/miele/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + 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 custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: + status: done + comment: | + Handled by a setting in manifest.json as there is no account information in API + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: exempt + comment: Handled by coordinator + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py new file mode 100644 index 00000000000..c281ba51151 --- /dev/null +++ b/homeassistant/components/miele/sensor.py @@ -0,0 +1,211 @@ +"""Sensor platform for Miele integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Final, cast + +from pymiele import MieleDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import STATE_STATUS_TAGS, MieleAppliance, StateStatus +from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .entity import MieleEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class MieleSensorDescription(SensorEntityDescription): + """Class describing Miele sensor entities.""" + + value_fn: Callable[[MieleDevice], StateType] + zone: int | None = None + + +@dataclass +class MieleSensorDefinition: + """Class for defining sensor entities.""" + + types: tuple[MieleAppliance, ...] + description: MieleSensorDescription + + +SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( + MieleSensorDefinition( + types=( + MieleAppliance.WASHING_MACHINE, + MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL, + MieleAppliance.TUMBLE_DRYER, + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.DISHWASHER, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.HOB_HIGHLIGHT, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.COFFEE_SYSTEM, + MieleAppliance.HOOD, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.ROBOT_VACUUM_CLEANER, + MieleAppliance.WASHER_DRYER, + MieleAppliance.DISH_WARMER, + MieleAppliance.HOB_INDUCTION, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + MieleAppliance.HOB_INDUCT_EXTR, + ), + description=MieleSensorDescription( + key="state_status", + translation_key="status", + value_fn=lambda value: value.state_status, + device_class=SensorDeviceClass.ENUM, + options=list(STATE_STATUS_TAGS.values()), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, + MieleAppliance.OVEN, + MieleAppliance.OVEN_MICROWAVE, + MieleAppliance.DISH_WARMER, + MieleAppliance.STEAM_OVEN, + MieleAppliance.MICROWAVE, + MieleAppliance.FRIDGE, + MieleAppliance.FREEZER, + MieleAppliance.FRIDGE_FREEZER, + MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.STEAM_OVEN_MICRO, + MieleAppliance.DIALOG_OVEN, + MieleAppliance.WINE_CABINET_FREEZER, + MieleAppliance.STEAM_OVEN_MK2, + ), + description=MieleSensorDescription( + key="state_temperature_1", + zone=1, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: cast(int, value.state_temperatures[0].temperature) + / 100.0, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MieleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + entities: list = [] + entity_class: type[MieleSensor] + for device_id, device in coordinator.data.devices.items(): + for definition in SENSOR_TYPES: + if device.device_type in definition.types: + match definition.description.key: + case "state_status": + entity_class = MieleStatusSensor + case _: + entity_class = MieleSensor + entities.append( + entity_class(coordinator, device_id, definition.description) + ) + + async_add_entities(entities) + + +APPLIANCE_ICONS = { + MieleAppliance.WASHING_MACHINE: "mdi:washing-machine", + MieleAppliance.TUMBLE_DRYER: "mdi:tumble-dryer", + MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "mdi:tumble-dryer", + MieleAppliance.DISHWASHER: "mdi:dishwasher", + MieleAppliance.OVEN: "mdi:chef-hat", + MieleAppliance.OVEN_MICROWAVE: "mdi:chef-hat", + MieleAppliance.HOB_HIGHLIGHT: "mdi:pot-steam-outline", + MieleAppliance.STEAM_OVEN: "mdi:chef-hat", + MieleAppliance.MICROWAVE: "mdi:microwave", + MieleAppliance.COFFEE_SYSTEM: "mdi:coffee-maker", + MieleAppliance.HOOD: "mdi:turbine", + MieleAppliance.FRIDGE: "mdi:fridge-industrial-outline", + MieleAppliance.FREEZER: "mdi:fridge-industrial-outline", + MieleAppliance.FRIDGE_FREEZER: "mdi:fridge-outline", + MieleAppliance.ROBOT_VACUUM_CLEANER: "mdi:robot-vacuum", + MieleAppliance.WASHER_DRYER: "mdi:washing-machine", + MieleAppliance.DISH_WARMER: "mdi:heat-wave", + MieleAppliance.HOB_INDUCTION: "mdi:pot-steam-outline", + MieleAppliance.STEAM_OVEN_COMBI: "mdi:chef-hat", + MieleAppliance.WINE_CABINET: "mdi:glass-wine", + MieleAppliance.WINE_CONDITIONING_UNIT: "mdi:glass-wine", + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "mdi:glass-wine", + MieleAppliance.STEAM_OVEN_MICRO: "mdi:chef-hat", + MieleAppliance.DIALOG_OVEN: "mdi:chef-hat", + MieleAppliance.WINE_CABINET_FREEZER: "mdi:glass-wine", + MieleAppliance.HOB_INDUCT_EXTR: "mdi:pot-steam-outline", +} + + +class MieleSensor(MieleEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: MieleSensorDescription + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) + + +class MieleStatusSensor(MieleSensor): + """Representation of the status sensor.""" + + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id, description) + self._attr_name = None + self._attr_icon = APPLIANCE_ICONS.get( + MieleAppliance(self.device.device_type), + "mdi:state-machine", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return STATE_STATUS_TAGS.get(StateStatus(self.device.state_status)) + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + # This sensor should always be available + return True diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json new file mode 100644 index 00000000000..c4348faa56c --- /dev/null +++ b/homeassistant/components/miele/strings.json @@ -0,0 +1,154 @@ +{ + "application_credentials": { + "description": "Navigate to [\"Get involved\" at Miele developer site]({register_url}) to request credentials then enter them below." + }, + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Miele integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "account_mismatch": "The used account does not match the original account", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "device": { + "coffee_system": { + "name": "Coffee system" + }, + "dishwasher": { + "name": "Dishwasher" + }, + "tumble_dryer": { + "name": "Tumble dryer" + }, + "fridge_freezer": { + "name": "Fridge freezer" + }, + "induction_hob": { + "name": "Induction hob" + }, + "oven": { + "name": "Oven" + }, + "oven_microwave": { + "name": "Oven microwave" + }, + "hob_highlight": { + "name": "Hob highlight" + }, + "steam_oven": { + "name": "Steam oven" + }, + "microwave": { + "name": "Microwave" + }, + "hood": { + "name": "Hood" + }, + "warming_drawer": { + "name": "Warming drawer" + }, + "steam_oven_combi": { + "name": "Steam oven combi" + }, + "wine_cabinet": { + "name": "Wine cabinet" + }, + "wine_conditioning_unit": { + "name": "Wine conditioning unit" + }, + "wine_unit": { + "name": "Wine unit" + }, + "refrigerator": { + "name": "Refrigerator" + }, + "freezer": { + "name": "Freezer" + }, + "robot_vacuum_cleander": { + "name": "Robot vacuum cleaner" + }, + "steam_oven_microwave": { + "name": "Steam oven micro" + }, + "dialog_oven": { + "name": "Dialog oven" + }, + "wine_cabinet_freezer": { + "name": "Wine cabinet freezer" + }, + "hob_extraction": { + "name": "How with extraction" + }, + "washer_dryer": { + "name": "Washer dryer" + }, + "washing_machine": { + "name": "Washing machine" + } + }, + "entity": { + "sensor": { + "status": { + "name": "Status", + "state": { + "autocleaning": "Automatic cleaning", + "failure": "Failure", + "idle": "[%key:common::state::idle%]", + "not_connected": "Not connected", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "pause": "Pause", + "program_ended": "Program ended", + "program_interrupted": "Program interrupted", + "programmed": "Programmed", + "rinse_hold": "Rinse hold", + "running": "Running", + "service": "Service", + "supercooling": "Supercooling", + "supercooling_superfreezing": "Supercooling/superfreezing", + "superfreezing": "Superfreezing", + "superheating": "Superheating", + "waiting_to_start": "Waiting to start" + } + } + } + }, + "exceptions": { + "config_entry_auth_failed": { + "message": "Authentication failed. Please log in again." + }, + "config_entry_not_ready": { + "message": "Error while loading the integration." + }, + "set_switch_error": { + "message": "Failed to set state for {entity}." + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index eaa4c657b56..2f088716f8c 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -21,6 +21,7 @@ APPLICATION_CREDENTIALS = [ "lyric", "mcp", "microbees", + "miele", "monzo", "myuplink", "neato", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 268d8c35f40..c53c83bad38 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -378,6 +378,7 @@ FLOWS = { "meteoclimatic", "metoffice", "microbees", + "miele", "mikrotik", "mill", "minecraft_server", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 276102d2032..e3dd9a4635f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3937,6 +3937,13 @@ } } }, + "miele": { + "name": "Miele", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "single_config_entry": true + }, "mijndomein_energie": { "name": "Mijndomein Energie", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 682b84dfe98..657a0006710 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2133,6 +2133,9 @@ pymeteoclimatic==0.1.0 # homeassistant.components.assist_pipeline pymicro-vad==1.0.1 +# homeassistant.components.miele +pymiele==0.3.4 + # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9c3fd3410b..9febfffa4e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,6 +1745,9 @@ pymeteoclimatic==0.1.0 # homeassistant.components.assist_pipeline pymicro-vad==1.0.1 +# homeassistant.components.miele +pymiele==0.3.4 + # homeassistant.components.mochad pymochad==0.2.0 diff --git a/tests/components/miele/__init__.py b/tests/components/miele/__init__.py new file mode 100644 index 00000000000..b0278defa8e --- /dev/null +++ b/tests/components/miele/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Miele 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/miele/conftest.py b/tests/components/miele/conftest.py new file mode 100644 index 00000000000..acb11e9135d --- /dev/null +++ b/tests/components/miele/conftest.py @@ -0,0 +1,145 @@ +"""Test helpers for Miele.""" + +from collections.abc import AsyncGenerator, Generator +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from pymiele import MieleAction, MieleDevices +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.miele.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CLIENT_ID, CLIENT_SECRET + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> float: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + minor_version=1, + domain=DOMAIN, + title="Miele test", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "Fake_token", + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + }, + entry_id="miele_test", + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + CLIENT_ID, + CLIENT_SECRET, + ), + DOMAIN, + ) + + +# Fixture group for device API endpoint. + + +@pytest.fixture(scope="package") +def load_device_file() -> str: + """Fixture for loading device file.""" + return "3_devices.json" + + +@pytest.fixture +def device_fixture(load_device_file: str) -> MieleDevices: + """Fixture for device.""" + return load_json_object_fixture(load_device_file, DOMAIN) + + +@pytest.fixture(scope="package") +def load_action_file() -> str: + """Fixture for loading action file.""" + return "action_washing_machine.json" + + +@pytest.fixture +def action_fixture(load_action_file: str) -> MieleAction: + """Fixture for action.""" + return load_json_object_fixture(load_action_file, DOMAIN) + + +@pytest.fixture +def mock_miele_client( + device_fixture, + action_fixture, +) -> Generator[MagicMock]: + """Mock a Miele client.""" + + with patch( + "homeassistant.components.miele.AsyncConfigEntryAuth", + autospec=True, + ) as mock_client: + client = mock_client.return_value + + client.get_devices.return_value = device_fixture + client.get_actions.return_value = action_fixture + + yield client + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms.""" + return [] + + +@pytest.fixture +async def setup_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms, +) -> AsyncGenerator[None]: + """Set up one or all platforms.""" + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield + + +@pytest.fixture +async def access_token(hass: HomeAssistant) -> str: + """Return a valid access token.""" + return "mock-access-token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.miele.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/miele/const.py b/tests/components/miele/const.py new file mode 100644 index 00000000000..fdc709229d2 --- /dev/null +++ b/tests/components/miele/const.py @@ -0,0 +1,5 @@ +"""Constants for miele tests.""" + +CLIENT_ID = "12345" +CLIENT_SECRET = "67890" +UNIQUE_ID = "uid" diff --git a/tests/components/miele/fixtures/3_devices.json b/tests/components/miele/fixtures/3_devices.json new file mode 100644 index 00000000000..b8562f38b86 --- /dev/null +++ b/tests/components/miele/fixtures/3_devices.json @@ -0,0 +1,359 @@ +{ + "Dummy_Appliance_1": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 20, + "value_localized": "Freezer" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_1", + "fabIndex": "21", + "techType": "FNS 28463 E ed/", + "matNumber": "10805070", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": -1800, + "value_localized": -18, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_2": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 19, + "value_localized": "Refrigerator" + }, + "deviceName": "", + "protocolVersion": 201, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_2", + "fabIndex": "17", + "techType": "KS 28423 D ed/c", + "matNumber": "10804770", + "swids": ["4497"] + }, + "xkmIdentLabel": { + "techType": "EK042", + "releaseVersion": "31.17" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + }, + "Dummy_Appliance_3": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 1, + "value_localized": "Washing machine" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "Dummy_Appliance_3", + "fabIndex": "44", + "techType": "WCI870", + "matNumber": "11387290", + "swids": [ + "5975", + "20456", + "25213", + "25191", + "25446", + "25205", + "25447", + "25319" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": true, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/action_freezer.json b/tests/components/miele/fixtures/action_freezer.json new file mode 100644 index 00000000000..9bfc7810a41 --- /dev/null +++ b/tests/components/miele/fixtures/action_freezer.json @@ -0,0 +1,21 @@ +{ + "processAction": [6], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": 1, + "max": 9 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/action_fridge.json b/tests/components/miele/fixtures/action_fridge.json new file mode 100644 index 00000000000..1d6e8832bae --- /dev/null +++ b/tests/components/miele/fixtures/action_fridge.json @@ -0,0 +1,21 @@ +{ + "processAction": [4], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": -28, + "max": -14 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/action_washing_machine.json b/tests/components/miele/fixtures/action_washing_machine.json new file mode 100644 index 00000000000..67e3a0666ff --- /dev/null +++ b/tests/components/miele/fixtures/action_washing_machine.json @@ -0,0 +1,15 @@ +{ + "processAction": [], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [], + "deviceName": true, + "powerOn": true, + "powerOff": false, + "colors": [], + "modes": [], + "runOnTime": [] +} diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr new file mode 100644 index 00000000000..eee976ab09f --- /dev/null +++ b/tests/components/miele/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'EK042', + 'id': , + 'identifiers': set({ + tuple( + 'miele', + 'Dummy_Appliance_1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Miele', + 'model': 'FNS 28463 E ed/', + 'model_id': None, + 'name': 'Freezer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'Dummy_Appliance_1', + 'suggested_area': None, + 'sw_version': '31.17', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..528880bf058 --- /dev/null +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -0,0 +1,375 @@ +# serializer version: 1 +# name: test_sensor_states[platforms0][sensor.freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + 'programmed', + 'waiting_to_start', + 'running', + 'pause', + 'program_ended', + 'failure', + 'program_interrupted', + 'idle', + 'rinse_hold', + 'service', + 'superfreezing', + 'supercooling', + 'superheating', + 'supercooling_superfreezing', + 'autocleaning', + 'not_connected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-industrial-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_1-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Freezer', + 'icon': 'mdi:fridge-industrial-outline', + 'options': list([ + 'off', + 'on', + 'programmed', + 'waiting_to_start', + 'running', + 'pause', + 'program_ended', + 'failure', + 'program_interrupted', + 'idle', + 'rinse_hold', + 'service', + 'superfreezing', + 'supercooling', + 'superheating', + 'supercooling_superfreezing', + 'autocleaning', + 'not_connected', + ]), + }), + 'context': , + 'entity_id': 'sensor.freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer_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.freezer_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': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_1-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Freezer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + 'programmed', + 'waiting_to_start', + 'running', + 'pause', + 'program_ended', + 'failure', + 'program_interrupted', + 'idle', + 'rinse_hold', + 'service', + 'superfreezing', + 'supercooling', + 'superheating', + 'supercooling_superfreezing', + 'autocleaning', + 'not_connected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-industrial-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_2-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Refrigerator', + 'icon': 'mdi:fridge-industrial-outline', + 'options': list([ + 'off', + 'on', + 'programmed', + 'waiting_to_start', + 'running', + 'pause', + 'program_ended', + 'failure', + 'program_interrupted', + 'idle', + 'rinse_hold', + 'service', + 'superfreezing', + 'supercooling', + 'superheating', + 'supercooling_superfreezing', + 'autocleaning', + 'not_connected', + ]), + }), + 'context': , + 'entity_id': 'sensor.refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Appliance_2-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.refrigerator_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'on', + 'programmed', + 'waiting_to_start', + 'running', + 'pause', + 'program_ended', + 'failure', + 'program_interrupted', + 'idle', + 'rinse_hold', + 'service', + 'superfreezing', + 'supercooling', + 'superheating', + 'supercooling_superfreezing', + 'autocleaning', + 'not_connected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:washing-machine', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Appliance_3-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing machine', + 'icon': 'mdi:washing-machine', + 'options': list([ + 'off', + 'on', + 'programmed', + 'waiting_to_start', + 'running', + 'pause', + 'program_ended', + 'failure', + 'program_interrupted', + 'idle', + 'rinse_hold', + 'service', + 'superfreezing', + 'supercooling', + 'superheating', + 'supercooling_superfreezing', + 'autocleaning', + 'not_connected', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/miele/test_config_flow.py b/tests/components/miele/test_config_flow.py new file mode 100644 index 00000000000..d05c77f42ca --- /dev/null +++ b/tests/components/miele/test_config_flow.py @@ -0,0 +1,214 @@ +"""Test the Miele config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock + +from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +import pytest + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import CLIENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +REDIRECT_URL = "https://example.com/auth/external/callback" + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: Generator[AsyncMock], +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&vg=sv-SE" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") is FlowResultType.EXTERNAL_STEP + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_reauth_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + access_token: str, + expires_at: float, +) -> None: + """Test reauth step with correct params.""" + + CURRENT_TOKEN = { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + } + assert hass.config_entries.async_update_entry( + mock_config_entry, + data=CURRENT_TOKEN, + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["step_id"] == "auth" + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&vg=sv-SE" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": "60", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_reconfigure_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + access_token: str, + expires_at: float, +) -> None: + """Test reauth step with correct params and mismatches.""" + + CURRENT_TOKEN = { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "expires_in": 86399, + "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", + "token_type": "Bearer", + "expires_at": expires_at, + }, + } + assert hass.config_entries.async_update_entry( + mock_config_entry, + data=CURRENT_TOKEN, + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["step_id"] == "auth" + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&vg=sv-SE" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": "60", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py new file mode 100644 index 00000000000..e4f1d27e565 --- /dev/null +++ b/tests/components/miele/test_init.py @@ -0,0 +1,120 @@ +"""Tests for init module.""" + +import http +import time +from unittest.mock import MagicMock + +from aiohttp import ClientConnectionError +from pymiele import OAUTH2_TOKEN +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = mock_config_entry + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test failure while refreshing token with a ClientError.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + exc=ClientConnectionError(), + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_devices_multiple_created_count( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that multiple devices are created.""" + await setup_integration(hass, mock_config_entry) + + assert len(device_registry.devices) == 3 + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "Dummy_Appliance_1")} + ) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py new file mode 100644 index 00000000000..c86aa84bd6a --- /dev/null +++ b/tests/components/miele/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for miele sensor module.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("platforms", [(Platform.SENSOR,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 7cf63d1985f0779f40769f893b63ed99072d763d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 13 Apr 2025 21:39:40 +0200 Subject: [PATCH 046/116] Add transition and flash feature flags for MQTT JSON light (#142692) --- .../components/mqtt/abbreviations.py | 2 + homeassistant/components/mqtt/const.py | 2 + .../components/mqtt/light/schema_json.py | 14 +- tests/components/mqtt/test_light_json.py | 138 ++++++++++++++++-- 4 files changed, 140 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index a9037a5f247..f0d000f79db 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -56,6 +56,7 @@ ABBREVIATIONS = { "ent_pic": "entity_picture", "evt_typ": "event_types", "fanspd_lst": "fan_speed_list", + "flsh": "flash", "flsh_tlng": "flash_time_long", "flsh_tsht": "flash_time_short", "fx_cmd_tpl": "effect_command_template", @@ -253,6 +254,7 @@ ABBREVIATIONS = { "tilt_status_tpl": "tilt_status_template", "tit": "title", "t": "topic", + "trns": "transition", "uniq_id": "unique_id", "unit_of_meas": "unit_of_measurement", "url_t": "url_topic", diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index b2fcd492435..090fc74aa88 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -87,6 +87,7 @@ CONF_EFFECT_TEMPLATE = "effect_template" CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" CONF_EXPIRE_AFTER = "expire_after" +CONF_FLASH = "flash" CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_SHORT = "flash_time_short" CONF_GREEN_TEMPLATE = "green_template" @@ -139,6 +140,7 @@ CONF_TEMP_STATE_TOPIC = "temperature_state_topic" CONF_TEMP_INITIAL = "initial" CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" +CONF_TRANSITION = "transition" CONF_XY_COMMAND_TEMPLATE = "xy_command_template" CONF_XY_COMMAND_TOPIC = "xy_command_topic" CONF_XY_STATE_TOPIC = "xy_state_topic" diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index a1f86278cf0..fc76d4bcf6c 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -59,6 +59,7 @@ from ..const import ( CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, CONF_EFFECT_LIST, + CONF_FLASH, CONF_FLASH_TIME_LONG, CONF_FLASH_TIME_SHORT, CONF_MAX_KELVIN, @@ -69,6 +70,7 @@ from ..const import ( CONF_RETAIN, CONF_STATE_TOPIC, CONF_SUPPORTED_COLOR_MODES, + CONF_TRANSITION, DEFAULT_BRIGHTNESS, DEFAULT_BRIGHTNESS_SCALE, DEFAULT_EFFECT, @@ -93,6 +95,9 @@ DOMAIN = "mqtt_json" DEFAULT_NAME = "MQTT JSON Light" +DEFAULT_FLASH = True +DEFAULT_TRANSITION = True + _PLATFORM_SCHEMA_BASE = ( MQTT_RW_SCHEMA.extend( { @@ -103,6 +108,7 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_FLASH, default=DEFAULT_FLASH): cv.boolean, vol.Optional( CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG ): cv.positive_int, @@ -125,6 +131,7 @@ _PLATFORM_SCHEMA_BASE = ( vol.Unique(), valid_supported_color_modes, ), + vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.boolean, vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All( vol.Coerce(int), vol.Range(min=1) ), @@ -199,12 +206,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): for key in (CONF_FLASH_TIME_SHORT, CONF_FLASH_TIME_LONG) } - self._attr_supported_features = ( - LightEntityFeature.TRANSITION | LightEntityFeature.FLASH - ) self._attr_supported_features |= ( config[CONF_EFFECT] and LightEntityFeature.EFFECT ) + self._attr_supported_features |= config[CONF_FLASH] and LightEntityFeature.FLASH + self._attr_supported_features |= ( + config[CONF_TRANSITION] and LightEntityFeature.TRANSITION + ) if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES): self._attr_supported_color_modes = supported_color_modes if self.supported_color_modes and len(self.supported_color_modes) == 1: diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index f3264858095..7f7f32c4e43 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -330,7 +330,9 @@ async def test_no_color_brightness_color_temp_if_no_topics( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None @@ -581,6 +583,104 @@ async def test_controlling_state_color_temp_kelvin( assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color +@pytest.mark.parametrize( + ("hass_config", "expected_features"), + [ + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + } + } + }, + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": True, + "transition": True, + } + } + }, + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": True, + "transition": False, + } + } + }, + light.LightEntityFeature.FLASH, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": False, + "transition": True, + } + } + }, + light.LightEntityFeature.TRANSITION, + ), + ( + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "flash": False, + "transition": False, + } + } + }, + light.LightEntityFeature(0), + ), + ], + ids=[ + "default", + "explicit_on", + "flash_only", + "transition_only", + "no_flash_not_transition", + ], +) +async def test_flash_and_transition_feature_flags( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_features: light.LightEntityFeature, +) -> None: + """Test for no RGB, brightness, color temp, effector XY.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features + + @pytest.mark.parametrize( "hass_config", [ @@ -601,9 +701,11 @@ async def test_controlling_state_via_topic( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("brightness") is None assert state.attributes.get("color_mode") is None assert state.attributes.get("color_temp_kelvin") is None @@ -799,9 +901,11 @@ async def test_sending_mqtt_commands_and_optimistic( state = hass.states.get("light.test") assert state.state == STATE_ON expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("brightness") == 95 assert state.attributes.get("color_mode") == "rgb" assert state.attributes.get("color_temp_kelvin") is None @@ -1457,9 +1561,11 @@ async def test_effect( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN expected_features = ( - light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test") @@ -1523,8 +1629,10 @@ async def test_flash_short_and_long( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test", flash="short") @@ -1586,8 +1694,10 @@ async def test_transition( state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features await common.async_turn_on(hass, "light.test", transition=15) mqtt_mock.async_publish.assert_called_once_with( @@ -1766,8 +1876,10 @@ async def test_invalid_values( assert state.state == STATE_UNKNOWN color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes - expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features assert state.attributes.get("rgb_color") is None assert state.attributes.get("brightness") is None assert state.attributes.get("color_temp_kelvin") is None From 6c7865a247319aad6bbc0ac74064bb666b76bfce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 10:08:01 -1000 Subject: [PATCH 047/116] Bump aioesphomeapi to 29.10.0 (#142813) --- 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 9f6431c940f..b82d90b10e5 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.9.0", + "aioesphomeapi==29.10.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.13.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 657a0006710..e9298c2c6c1 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.9.0 +aioesphomeapi==29.10.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9febfffa4e7..07fa3dcb61b 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.9.0 +aioesphomeapi==29.10.0 # homeassistant.components.flo aioflo==2021.11.0 From 4f0928d93b8f52f3333afea2bf6c39165666b312 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 13 Apr 2025 22:08:19 +0200 Subject: [PATCH 048/116] Use existing translations for mqtt subentry platform selector (#142876) --- homeassistant/components/mqtt/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 542b16bab80..bc9fd06c78c 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -472,9 +472,9 @@ }, "platform": { "options": { - "notify": "Notify", - "sensor": "Sensor", - "switch": "Switch" + "notify": "[%key:component::notify::title%]", + "sensor": "[%key:component::sensor::title%]", + "switch": "[%key:component::switch::title%]" } }, "set_ca_cert": { From 18c814d3dc2cfe08e0aed2fbb850c68fab8b22f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 10:08:28 -1000 Subject: [PATCH 049/116] Bump inkbird-ble to 0.11.0 (#142832) --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index ea980babf7e..b3dbb7742ff 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -40,5 +40,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.10.1"] + "requirements": ["inkbird-ble==0.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9298c2c6c1..a0ab11a23b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1235,7 +1235,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.10.1 +inkbird-ble==0.11.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07fa3dcb61b..e72281825f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1050,7 +1050,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.10.1 +inkbird-ble==0.11.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 8ab59bee47b1f6596644a7319e6b914cf40fe282 Mon Sep 17 00:00:00 2001 From: zry98 Date: Sun, 13 Apr 2025 22:41:43 +0200 Subject: [PATCH 050/116] [xiaomi_ble] Support Body Composition Scale S400 (#142705) --- homeassistant/components/xiaomi_ble/sensor.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 57dfaead232..0fcae1925bb 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -177,6 +177,25 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), + # Low frequency impedance sensor (ohm) + (ExtendedSensorDeviceClass.IMPEDANCE_LOW, Units.OHM): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.IMPEDANCE_LOW), + native_unit_of_measurement=Units.OHM, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:omega", + ), + # Heart rate sensor (bpm) + (ExtendedSensorDeviceClass.HEART_RATE, "bpm"): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.HEART_RATE), + native_unit_of_measurement="bpm", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:heart-pulse", + ), + # User profile ID sensor + (ExtendedSensorDeviceClass.PROFILE_ID, None): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.PROFILE_ID), + icon="mdi:identifier", + ), } From 8b88272bc09d0ebd9e587061ee4af70b67f99577 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 11:08:22 -1000 Subject: [PATCH 051/116] Add async_set_updated_data method to PassiveBluetoothProcessorCoordinator (#142879) --- .../bluetooth/passive_update_processor.py | 21 ++++ .../test_passive_update_processor.py | 105 ++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8f66a3582ea..09e953a8676 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -374,6 +374,27 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat self.logger.exception("Unexpected error updating %s data", self.name) return + self._process_update(update, was_available) + + @callback + def async_set_updated_data(self, update: _DataT) -> None: + """Manually update the processor with new data. + + If the data comes in via a different method, like a + notification, this method can be used to update the + processor with the new data. + + This is useful for devices that retrieve + some of their data via notifications. + """ + was_available = self._available + self._available = True + self._process_update(update, was_available) + + def _process_update( + self, update: _DataT, was_available: bool | None = None + ) -> None: + """Process the update from the bluetooth device.""" if not self.last_update_success: self.last_update_success = True self.logger.info("Coordinator %s recovered", self.name) diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index e9274965e3c..5d4dfcf103f 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -273,6 +273,111 @@ async def test_basic_usage(hass: HomeAssistant) -> None: cancel_coordinator() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_async_set_updated_data_usage(hass: HomeAssistant) -> None: + """Test async_set_updated_data of the PassiveBluetoothProcessorCoordinator.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + assert data == {"test": "data"} + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + + unregister_processor = coordinator.async_register_processor(processor) + cancel_coordinator = coordinator.async_start() + + entity_key = PassiveBluetoothEntityKey("temperature", None) + entity_key_events = [] + all_events = [] + mock_entity = MagicMock() + mock_add_entities = MagicMock() + + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) + + cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener( + _async_entity_key_listener, + entity_key, + ) + + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) + + cancel_listener = processor.async_add_listener( + _all_listener, + ) + + cancel_async_add_entities_listener = processor.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) + + assert coordinator.available is False + coordinator.async_set_updated_data({"test": "data"}) + assert coordinator.available is True + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # Each listener should receive the same data + # since both match, and an additional all_events + # for the async_set_updated_data call + assert len(entity_key_events) == 1 + assert len(all_events) == 2 + + # There should be 4 calls to create entities + assert len(mock_entity.mock_calls) == 2 + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + + # Only the all listener should receive the new data + # since temperature is not in the new data, and an additional all_events + # for the async_set_updated_data call + assert len(entity_key_events) == 1 + assert len(all_events) == 3 + + # On the second, the entities should already be created + # so the mock should not be called again + assert len(mock_entity.mock_calls) == 2 + + cancel_async_add_entity_key_listener() + cancel_listener() + cancel_async_add_entities_listener() + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # Each listener should not trigger any more now + # that they were cancelled + assert len(entity_key_events) == 1 + assert len(all_events) == 3 + assert len(mock_entity.mock_calls) == 2 + assert coordinator.available is True + + unregister_processor() + cancel_coordinator() + + @pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_entity_key_is_dispatched_on_entity_key_change( hass: HomeAssistant, From d91528648fda9d440678576fc323806f2e448f8c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 13 Apr 2025 15:37:46 -0700 Subject: [PATCH 052/116] Update ollama to allow selecting mutiple LLM APIs (#142445) * Update ollama to allow selecting mutiple LLM APIs * Update homeassistant/helpers/llm.py * Avoid gather since these don't do I/O --------- Co-authored-by: Paulus Schoutsen --- .../components/conversation/chat_log.py | 2 +- .../components/ollama/config_flow.py | 13 +- homeassistant/helpers/llm.py | 120 +++++++++++++++++- .../components/conversation/test_chat_log.py | 42 ++++++ tests/helpers/test_llm.py | 60 ++++++++- 5 files changed, 219 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 9ffcc7fc0d5..8d8a17a5259 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -387,7 +387,7 @@ class ChatLog: self, conversing_domain: str, user_input: ConversationInput, - user_llm_hass_api: str | None = None, + user_llm_hass_api: str | list[str] | None = None, user_llm_prompt: str | None = None, ) -> None: """Set the LLM system prompt.""" diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 1024a824c25..7379ea17ba6 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -215,8 +215,6 @@ class OllamaOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) return self.async_create_entry( title=_get_title(self.model), data=user_input ) @@ -234,18 +232,12 @@ def ollama_config_option_schema( ) -> dict: """Ollama options schema.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] return { vol.Optional( @@ -259,8 +251,7 @@ def ollama_config_option_schema( vol.Optional( CONF_LLM_HASS_API, description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Optional( CONF_NUM_CTX, description={"suggested_value": options.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index aa6b3dc2cbf..24062ba1521 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -110,15 +110,29 @@ def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]: async def async_get_api( - hass: HomeAssistant, api_id: str, llm_context: LLMContext + hass: HomeAssistant, api_id: str | list[str], llm_context: LLMContext ) -> APIInstance: - """Get an API.""" + """Get an API. + + This returns a single APIInstance for one or more API ids, merging into + a single instance of necessary. + """ apis = _async_get_apis(hass) - if api_id not in apis: - raise HomeAssistantError(f"API {api_id} not found") + if isinstance(api_id, str): + api_id = [api_id] - return await apis[api_id].async_get_api_instance(llm_context) + for key in api_id: + if key not in apis: + raise HomeAssistantError(f"API {key} not found") + + api: API + if len(api_id) == 1: + api = apis[api_id[0]] + else: + api = MergedAPI([apis[key] for key in api_id]) + + return await api.async_get_api_instance(llm_context) @callback @@ -286,6 +300,102 @@ class IntentTool(Tool): return response +class NamespacedTool(Tool): + """A tool that wraps another tool, prepending a namespace. + + This is used to support tools from multiple API. This tool dispatches + the original tool with the original non-namespaced name. + """ + + def __init__(self, namespace: str, tool: Tool) -> None: + """Init the class.""" + self.namespace = namespace + self.name = f"{namespace}.{tool.name}" + self.description = tool.description + self.parameters = tool.parameters + self.tool = tool + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Handle the intent.""" + return await self.tool.async_call( + hass, + ToolInput( + tool_name=self.tool.name, + tool_args=tool_input.tool_args, + id=tool_input.id, + ), + llm_context, + ) + + +class MergedAPI(API): + """An API that represents a merged view of multiple APIs.""" + + def __init__(self, llm_apis: list[API]) -> None: + """Init the class.""" + if not llm_apis: + raise ValueError("No APIs provided") + hass = llm_apis[0].hass + api_ids = [unicode_slug.slugify(api.id) for api in llm_apis] + if len(set(api_ids)) != len(api_ids): + raise ValueError("API IDs must be unique") + super().__init__( + hass=hass, + id="|".join(unicode_slug.slugify(api.id) for api in llm_apis), + name="Merged LLM API", + ) + self.llm_apis = llm_apis + + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: + """Return the instance of the API.""" + # These usually don't do I/O and execute right away + llm_apis = [ + await llm_api.async_get_api_instance(llm_context) + for llm_api in self.llm_apis + ] + prompt_parts = [] + tools: list[Tool] = [] + for api_instance in llm_apis: + namespace = unicode_slug.slugify(api_instance.api.name) + prompt_parts.append( + f'Follow these instructions for tools from "{namespace}":\n' + ) + prompt_parts.append(api_instance.api_prompt) + prompt_parts.append("\n\n") + tools.extend( + [NamespacedTool(namespace, tool) for tool in api_instance.tools] + ) + + return APIInstance( + api=self, + api_prompt="".join(prompt_parts), + llm_context=llm_context, + tools=tools, + custom_serializer=self._custom_serializer(llm_apis), + ) + + def _custom_serializer( + self, llm_apis: list[APIInstance] + ) -> Callable[[Any], Any] | None: + serializers = [ + api_instance.custom_serializer + for api_instance in llm_apis + if api_instance.custom_serializer is not None + ] + if not serializers: + return None + + def merged(x: Any) -> Any: + for serializer in serializers: + if (result := serializer(x)) is not None: + return result + return x + + return merged + + class AssistAPI(API): """API exposing Assist API to LLMs.""" diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index d7b3531c658..c9e72ae5a03 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -139,6 +139,48 @@ async def test_unknown_llm_api( assert exc_info.value.as_conversation_result().as_dict() == snapshot +async def test_multiple_llm_apis( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test when we reference an LLM API.""" + + class MyTool(llm.Tool): + """Test tool.""" + + name = "test_tool" + description = "Test function" + parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + + class MyAPI(llm.API): + """Test API.""" + + async def async_get_api_instance( + self, llm_context: llm.LLMContext + ) -> llm.APIInstance: + """Return a list of tools.""" + return llm.APIInstance(self, "My API Prompt", llm_context, [MyTool()]) + + api = MyAPI(hass=hass, id="my-api", name="Test") + llm.async_register_api(hass, api) + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api=["assist", "my-api"], + user_llm_prompt=None, + ) + + assert chat_log.llm_api + assert chat_log.llm_api.api.id == "assist|my-api" + + async def test_template_error( hass: HomeAssistant, mock_conversation_input: ConversationInput, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 26c357c4b0a..23c2eef1765 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -25,6 +25,7 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType from tests.common import MockConfigEntry, async_mock_service @@ -45,9 +46,12 @@ def llm_context() -> llm.LLMContext: class MyAPI(llm.API): """Test API.""" + prompt: str = "" + tools: list[llm.Tool] = [] + async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: """Return a list of tools.""" - return llm.APIInstance(self, "", [], llm_context) + return llm.APIInstance(self, self.prompt, llm_context, self.tools) async def test_get_api_no_existing( @@ -1326,3 +1330,57 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.tools == [] + + +async def test_merged_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test an API instance that merges multiple llm apis.""" + + class MyTool(llm.Tool): + def __init__(self, name: str, description: str) -> None: + self.name = name + self.description = description + + async def async_call( + self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext + ) -> JsonObjectType: + return {"result": {tool_input.tool_name: tool_input.tool_args}} + + api1 = MyAPI(hass=hass, id="api-1", name="API 1") + api1.prompt = "This is prompt 1" + api1.tools = [MyTool(name="Tool_1", description="Description 1")] + llm.async_register_api(hass, api1) + + api2 = MyAPI(hass=hass, id="api-2", name="API 2") + api2.prompt = "This is prompt 2" + api2.tools = [MyTool(name="Tool_2", description="Description 2")] + llm.async_register_api(hass, api2) + + instance = await llm.async_get_api(hass, ["api-1", "api-2"], llm_context) + assert instance.api.id == "api-1|api-2" + + assert ( + instance.api_prompt + == """Follow these instructions for tools from "api-1": +This is prompt 1 + +Follow these instructions for tools from "api-2": +This is prompt 2 + +""" + ) + assert [(tool.name, tool.description) for tool in instance.tools] == [ + ("api-1.Tool_1", "Description 1"), + ("api-2.Tool_2", "Description 2"), + ] + + # The test tool returns back the provided arguments so we can verify + # the original tool is invoked with the correct tool name and args. + result = await instance.async_call_tool( + llm.ToolInput(tool_name="api-1.Tool_1", tool_args={"arg1": "value1"}) + ) + assert result == {"result": {"Tool_1": {"arg1": "value1"}}} + + result = await instance.async_call_tool( + llm.ToolInput(tool_name="api-2.Tool_2", tool_args={"arg2": "value2"}) + ) + assert result == {"result": {"Tool_2": {"arg2": "value2"}}} From 5b8ca8d0ed6e3a1c9a60151973bbfe264028ab2a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 13 Apr 2025 17:42:24 -0700 Subject: [PATCH 053/116] Improve local calendar error logging when uploading invalid .ics files (#142891) --- .../components/local_calendar/config_flow.py | 13 ++++++++++--- .../components/local_calendar/strings.json | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py index fef45f786f9..f5b3220fb8c 100644 --- a/homeassistant/components/local_calendar/config_flow.py +++ b/homeassistant/components/local_calendar/config_flow.py @@ -97,8 +97,7 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_ICS_FILE], self.data[CONF_STORAGE_KEY], ) - except HomeAssistantError as err: - _LOGGER.debug("Error saving uploaded file: %s", err) + except InvalidIcsFile: errors[CONF_ICS_FILE] = "invalid_ics_file" else: return self.async_create_entry( @@ -112,6 +111,10 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN): ) +class InvalidIcsFile(HomeAssistantError): + """Error to indicate that the uploaded file is not a valid ICS file.""" + + def save_uploaded_ics_file( hass: HomeAssistant, uploaded_file_id: str, storage_key: str ): @@ -122,6 +125,10 @@ def save_uploaded_ics_file( try: CalendarStream.from_ics(ics) except CalendarParseError as err: - raise HomeAssistantError("Failed to upload file: Invalid ICS file") from err + _LOGGER.error("Error reading the calendar information: %s", err.message) + _LOGGER.debug( + "Additional calendar error detail: %s", str(err.detailed_error) + ) + raise InvalidIcsFile("Failed to upload file: Invalid ICS file") from err dest_path = Path(hass.config.path(STORAGE_PATH.format(key=storage_key))) shutil.move(file, dest_path) diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json index 2b61fc9ab3e..6d68b46b5b0 100644 --- a/homeassistant/components/local_calendar/strings.json +++ b/homeassistant/components/local_calendar/strings.json @@ -17,7 +17,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { - "invalid_ics_file": "Invalid .ics file" + "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "selector": { From 658299ee21c11b51bafa83dcec0d1171b5ba0743 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 13 Apr 2025 17:42:42 -0700 Subject: [PATCH 054/116] Strip whitespace from new todo list item names (#142889) Strip whitspace from new todo list item names --- homeassistant/components/todo/__init__.py | 8 ++++++-- tests/components/todo/test_init.py | 23 +++++++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index c1c921343b8..b8c90f917d4 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -129,7 +129,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.All( cv.make_entity_service_schema( { - vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)), + vol.Required(ATTR_ITEM): vol.All( + cv.string, str.strip, vol.Length(min=1) + ), **TODO_ITEM_FIELD_SCHEMA, } ), @@ -144,7 +146,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cv.make_entity_service_schema( { vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)), - vol.Optional(ATTR_RENAME): vol.All(cv.string, vol.Length(min=1)), + vol.Optional(ATTR_RENAME): vol.All( + cv.string, str.strip, vol.Length(min=1) + ), vol.Optional(ATTR_STATUS): vol.In( {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED}, ), diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 11ef3d6f044..adada97a9e4 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -160,9 +160,18 @@ async def test_unsupported_websocket( assert resp.get("error", {}).get("code") == "not_found" +@pytest.mark.parametrize( + ("new_item_name"), + [ + ("New item"), + ("New item "), + (" New item"), + ], +) async def test_add_item_service( hass: HomeAssistant, test_entity: TodoListEntity, + new_item_name: str, ) -> None: """Test adding an item in a To-do list.""" @@ -171,7 +180,7 @@ async def test_add_item_service( await hass.services.async_call( DOMAIN, TodoServices.ADD_ITEM, - {ATTR_ITEM: "New item"}, + {ATTR_ITEM: new_item_name}, target={ATTR_ENTITY_ID: "todo.entity1"}, blocking=True, ) @@ -209,6 +218,7 @@ async def test_add_item_service_raises( [ ({}, vol.Invalid, "required key not provided"), ({ATTR_ITEM: ""}, vol.Invalid, "length of value must be at least 1"), + ({ATTR_ITEM: " "}, vol.Invalid, "length of value must be at least 1"), ( {ATTR_ITEM: "Submit forms", ATTR_DESCRIPTION: "Submit tax forms"}, ServiceValidationError, @@ -331,9 +341,18 @@ async def test_add_item_service_extended_fields( assert item == expected_item +@pytest.mark.parametrize( + ("new_item_name"), + [ + ("Updated item"), + ("Updated item "), + (" Updated item "), + ], +) async def test_update_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, + new_item_name: str, ) -> None: """Test updating an item in a To-do list.""" @@ -342,7 +361,7 @@ async def test_update_todo_item_service_by_id( await hass.services.async_call( DOMAIN, TodoServices.UPDATE_ITEM, - {ATTR_ITEM: "1", ATTR_RENAME: "Updated item", ATTR_STATUS: "completed"}, + {ATTR_ITEM: "1", ATTR_RENAME: new_item_name, ATTR_STATUS: "completed"}, target={ATTR_ENTITY_ID: "todo.entity1"}, blocking=True, ) From 1a1c95af12ea51ac94396b84e95c43d3ef62ee58 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Mon, 14 Apr 2025 00:39:50 -0400 Subject: [PATCH 055/116] Bump Environment Canada library to 0.10.1 (#142882) --- .../components/environment_canada/config_flow.py | 2 +- .../components/environment_canada/coordinator.py | 4 ++-- .../components/environment_canada/manifest.json | 2 +- .../components/environment_canada/sensor.py | 12 ++++++------ .../components/environment_canada/weather.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/environment_canada/__init__.py | 2 +- tests/components/environment_canada/conftest.py | 7 +++++-- .../environment_canada/test_config_flow.py | 2 +- .../environment_canada/test_diagnostics.py | 6 ------ 11 files changed, 20 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index c4fd16f9522..debe1c5ae43 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -35,7 +35,7 @@ async def validate_input(data): lon = weather_data.lon return { - CONF_TITLE: weather_data.metadata.get("location"), + CONF_TITLE: weather_data.metadata.location, CONF_STATION: weather_data.station_id, CONF_LATITUDE: lat, CONF_LONGITUDE: lon, diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index e31e847cd2d..89fc92b462e 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -7,7 +7,7 @@ from datetime import timedelta import logging import xml.etree.ElementTree as ET -from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc +from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -65,6 +65,6 @@ class ECDataUpdateCoordinator[DataT: ECDataType](DataUpdateCoordinator[DataT]): """Fetch data from EC.""" try: await self.ec_data.update() - except (ET.ParseError, ec_exc.UnknownStationId) as ex: + except (ET.ParseError, ECWeatherUpdateFailed, ec_exc.UnknownStationId) as ex: raise UpdateFailed(f"Error fetching {self.name} data: {ex}") from ex return self.ec_data diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index fc05e093b33..098f231a40f 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.8.0"] + "requirements": ["env-canada==0.10.1"] } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 1685888d2bc..d27da132a35 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -145,7 +145,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( key="timestamp", translation_key="timestamp", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: data.metadata.get("timestamp"), + value_fn=lambda data: data.metadata.timestamp, ), ECSensorEntityDescription( key="uv_index", @@ -289,7 +289,7 @@ class ECBaseSensorEntity[DataT: ECDataType]( super().__init__(coordinator) self.entity_description = description self._ec_data = coordinator.ec_data - self._attr_attribution = self._ec_data.metadata["attribution"] + self._attr_attribution = self._ec_data.metadata.attribution self._attr_unique_id = f"{coordinator.config_entry.title}-{description.key}" self._attr_device_info = coordinator.device_info @@ -313,8 +313,8 @@ class ECSensorEntity[DataT: ECDataType](ECBaseSensorEntity[DataT]): """Initialize the sensor.""" super().__init__(coordinator, description) self._attr_extra_state_attributes = { - ATTR_LOCATION: self._ec_data.metadata.get("location"), - ATTR_STATION: self._ec_data.metadata.get("station"), + ATTR_LOCATION: self._ec_data.metadata.location, + ATTR_STATION: self._ec_data.metadata.station, } @@ -329,8 +329,8 @@ class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]): return None extra_state_attrs = { - ATTR_LOCATION: self._ec_data.metadata.get("location"), - ATTR_STATION: self._ec_data.metadata.get("station"), + ATTR_LOCATION: self._ec_data.metadata.location, + ATTR_STATION: self._ec_data.metadata.station, } for index, alert in enumerate(value, start=1): extra_state_attrs[f"alert_{index}"] = alert.get("title") diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index dd7632032ec..a5acb224bd0 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -115,7 +115,7 @@ class ECWeatherEntity( """Initialize Environment Canada weather.""" super().__init__(coordinator) self.ec_data = coordinator.ec_data - self._attr_attribution = self.ec_data.metadata["attribution"] + self._attr_attribution = self.ec_data.metadata.attribution self._attr_translation_key = "forecast" self._attr_unique_id = _calculate_unique_id( coordinator.config_entry.unique_id, False diff --git a/requirements_all.txt b/requirements_all.txt index a0ab11a23b8..bfb00efcf2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -871,7 +871,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.8.0 +env-canada==0.10.1 # homeassistant.components.season ephem==4.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e72281825f6..46e70361923 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -741,7 +741,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.8.0 +env-canada==0.10.1 # homeassistant.components.season ephem==4.1.6 diff --git a/tests/components/environment_canada/__init__.py b/tests/components/environment_canada/__init__.py index edc7a92a12f..61ec97ef794 100644 --- a/tests/components/environment_canada/__init__.py +++ b/tests/components/environment_canada/__init__.py @@ -33,7 +33,7 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry: config_entry.add_to_hass(hass) weather_mock = mock_ec() - ec_data["metadata"]["timestamp"] = datetime(2022, 10, 4, tzinfo=UTC) + ec_data["metadata"].timestamp = datetime(2022, 10, 4, tzinfo=UTC) weather_mock.conditions = ec_data["conditions"] weather_mock.alerts = ec_data["alerts"] weather_mock.daily_forecasts = ec_data["daily_forecasts"] diff --git a/tests/components/environment_canada/conftest.py b/tests/components/environment_canada/conftest.py index 19180052c93..3c7683ad0eb 100644 --- a/tests/components/environment_canada/conftest.py +++ b/tests/components/environment_canada/conftest.py @@ -4,6 +4,7 @@ import contextlib from datetime import datetime import json +from env_canada.ec_weather import MetaData import pytest from tests.common import load_fixture @@ -13,7 +14,7 @@ from tests.common import load_fixture def ec_data(): """Load Environment Canada data.""" - def date_hook(weather): + def data_hook(weather): """Convert timestamp string to datetime.""" if t := weather.get("timestamp"): @@ -22,9 +23,11 @@ def ec_data(): elif t := weather.get("period"): with contextlib.suppress(ValueError): weather["period"] = datetime.fromisoformat(t) + if t := weather.get("metadata"): + weather["metadata"] = MetaData(**t) return weather return json.loads( load_fixture("environment_canada/current_conditions_data.json"), - object_hook=date_hook, + object_hook=data_hook, ) diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index d61966e8da1..9f3fdbd43dc 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -30,7 +30,7 @@ def mocked_ec(): ec_mock.lat = FAKE_CONFIG[CONF_LATITUDE] ec_mock.lon = FAKE_CONFIG[CONF_LONGITUDE] ec_mock.language = FAKE_CONFIG[CONF_LANGUAGE] - ec_mock.metadata = {"location": FAKE_TITLE} + ec_mock.metadata.location = FAKE_TITLE ec_mock.update = AsyncMock() diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index 79b72961124..7c35c33f93a 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -1,6 +1,5 @@ """Test Environment Canada diagnostics.""" -import json from typing import Any from syrupy import SnapshotAssertion @@ -11,7 +10,6 @@ from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -31,10 +29,6 @@ async def test_entry_diagnostics( ) -> None: """Test config entry diagnostics.""" - ec_data = json.loads( - load_fixture("environment_canada/current_conditions_data.json") - ) - config_entry = await init_integration(hass, ec_data) diagnostics = await get_diagnostics_for_config_entry( hass, hass_client, config_entry From cc6e2ef3f7a00e17da475940f1386d177d9162c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Mon, 14 Apr 2025 09:36:02 +0200 Subject: [PATCH 056/116] Spelling corrections in miele integration (#142907) Spelling corrections --- homeassistant/components/miele/const.py | 4 ++-- homeassistant/components/miele/strings.json | 4 ++-- .../components/miele/snapshots/test_sensor.ambr | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 48bb724b888..86239ee6590 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -92,7 +92,7 @@ class StateStatus(IntEnum): ON = 2 PROGRAMMED = 3 WAITING_TO_START = 4 - RUNNING = 5 + IN_USE = 5 PAUSE = 6 PROGRAM_ENDED = 7 FAILURE = 8 @@ -113,7 +113,7 @@ STATE_STATUS_TAGS = { StateStatus.ON: "on", StateStatus.PROGRAMMED: "programmed", StateStatus.WAITING_TO_START: "waiting_to_start", - StateStatus.RUNNING: "running", + StateStatus.IN_USE: "in_use", StateStatus.PAUSE: "pause", StateStatus.PROGRAM_ENDED: "program_ended", StateStatus.FAILURE: "failure", diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index c4348faa56c..a25d0613a81 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -104,7 +104,7 @@ "name": "Wine cabinet freezer" }, "hob_extraction": { - "name": "How with extraction" + "name": "Hob with extraction" }, "washer_dryer": { "name": "Washer dryer" @@ -129,7 +129,7 @@ "program_interrupted": "Program interrupted", "programmed": "Programmed", "rinse_hold": "Rinse hold", - "running": "Running", + "in_use": "In use", "service": "Service", "supercooling": "Supercooling", "supercooling_superfreezing": "Supercooling/superfreezing", diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 528880bf058..0a29ec46472 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -10,7 +10,7 @@ 'on', 'programmed', 'waiting_to_start', - 'running', + 'in_use', 'pause', 'program_ended', 'failure', @@ -65,7 +65,7 @@ 'on', 'programmed', 'waiting_to_start', - 'running', + 'in_use', 'pause', 'program_ended', 'failure', @@ -86,7 +86,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'running', + 'state': 'in_use', }) # --- # name: test_sensor_states[platforms0][sensor.freezer_temperature-entry] @@ -152,7 +152,7 @@ 'on', 'programmed', 'waiting_to_start', - 'running', + 'in_use', 'pause', 'program_ended', 'failure', @@ -207,7 +207,7 @@ 'on', 'programmed', 'waiting_to_start', - 'running', + 'in_use', 'pause', 'program_ended', 'failure', @@ -228,7 +228,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'running', + 'state': 'in_use', }) # --- # name: test_sensor_states[platforms0][sensor.refrigerator_temperature-entry] @@ -294,7 +294,7 @@ 'on', 'programmed', 'waiting_to_start', - 'running', + 'in_use', 'pause', 'program_ended', 'failure', @@ -349,7 +349,7 @@ 'on', 'programmed', 'waiting_to_start', - 'running', + 'in_use', 'pause', 'program_ended', 'failure', From 3389ee4b80394e8469a9a338d3de1772d63e584d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 21:37:42 -1000 Subject: [PATCH 057/116] Bump inkbird-ble to 0.13.0 (#142885) * Bump inkbird-ble to 0.12.0 changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.11.0...v0.12.0 * map discovery as well * fix merge * fix merge error * bump again for more cleanups * fix tests --- .../components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/inkbird/__init__.py | 48 ++++++++++++++++--- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index b3dbb7742ff..2e23663f5ff 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -40,5 +40,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.11.0"] + "requirements": ["inkbird-ble==0.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bfb00efcf2e..5b902ce7ad8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1235,7 +1235,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.11.0 +inkbird-ble==0.13.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46e70361923..5d3ce9687f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1050,7 +1050,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.11.0 +inkbird-ble==0.13.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index e285e1cbf2d..63acff7a150 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -1,8 +1,44 @@ """Tests for the INKBIRD integration.""" -from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo +from uuid import UUID -NOT_INKBIRD_SERVICE_INFO = BluetoothServiceInfo( +from bleak.backends.device import BLEDevice + +from homeassistant.components.bluetooth import MONOTONIC_TIME, BluetoothServiceInfoBleak + + +def _make_bluetooth_service_info( + name: str, + manufacturer_data: dict[int, bytes], + service_uuids: list[str], + address: str, + rssi: int, + service_data: dict[UUID, bytes], + source: str, + tx_power: int = 0, +) -> BluetoothServiceInfoBleak: + return BluetoothServiceInfoBleak( + name=name, + manufacturer_data=manufacturer_data, + service_uuids=service_uuids, + address=address, + rssi=rssi, + service_data=service_data, + source=source, + device=BLEDevice( + name=name, + address=address, + details={}, + rssi=rssi, + ), + time=MONOTONIC_TIME(), + advertisement=None, + connectable=True, + tx_power=tx_power, + ) + + +NOT_INKBIRD_SERVICE_INFO = _make_bluetooth_service_info( name="Not it", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -12,7 +48,7 @@ NOT_INKBIRD_SERVICE_INFO = BluetoothServiceInfo( source="local", ) -SPS_SERVICE_INFO = BluetoothServiceInfo( +SPS_SERVICE_INFO = _make_bluetooth_service_info( name="sps", address="61DE521B-F0BF-9F44-64D4-75BBE1738105", rssi=-63, @@ -23,7 +59,7 @@ SPS_SERVICE_INFO = BluetoothServiceInfo( ) -SPS_PASSIVE_SERVICE_INFO = BluetoothServiceInfo( +SPS_PASSIVE_SERVICE_INFO = _make_bluetooth_service_info( name="sps", address="AA:BB:CC:DD:EE:FF", rssi=-63, @@ -34,7 +70,7 @@ SPS_PASSIVE_SERVICE_INFO = BluetoothServiceInfo( ) -SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( +SPS_WITH_CORRUPT_NAME_SERVICE_INFO = _make_bluetooth_service_info( name="XXXXcorruptXXXX", address="AA:BB:CC:DD:EE:FF", rssi=-63, @@ -45,7 +81,7 @@ SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( ) -IBBQ_SERVICE_INFO = BluetoothServiceInfo( +IBBQ_SERVICE_INFO = _make_bluetooth_service_info( name="iBBQ", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", rssi=-56, From 62a0932deb345f34ebd302d0bbcafe85ed88a7a2 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Mon, 14 Apr 2025 09:39:01 +0200 Subject: [PATCH 058/116] Only get tracked pairs for kraken (#142877) Only get tracked pairs Getting all available pairs leads to a too long request URL --- homeassistant/components/kraken/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 9a90e77f2b6..c981f3fd438 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -145,7 +145,10 @@ class KrakenData: await asyncio.sleep(CALL_RATE_LIMIT_SLEEP) def _get_websocket_name_asset_pairs(self) -> str: - return ",".join(wsname for wsname in self.tradable_asset_pairs.values()) + return ",".join( + self.tradable_asset_pairs[tracked_pair] + for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS] + ) def set_update_interval(self, update_interval: int) -> None: """Set the coordinator update_interval to the supplied update_interval.""" From 61f2251336112c4087680afb6b626ded51ccdeb6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 14 Apr 2025 09:39:41 +0200 Subject: [PATCH 059/116] Fix Reolink Home Hub Pro playback (#142871) Fix Home Hub Pro playback --- homeassistant/components/reolink/media_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 39514d58cb7..092f0d4ddca 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -70,7 +70,7 @@ class ReolinkVODMediaSource(MediaSource): host = get_host(self.hass, config_entry_id) def get_vod_type() -> VodRequestType: - if filename.endswith((".mp4", ".vref")): + if filename.endswith((".mp4", ".vref")) or host.api.is_hub: if host.api.is_nvr: return VodRequestType.DOWNLOAD return VodRequestType.PLAYBACK From 0689a6ed62582cad91956ae5f58c9e01436ea8e2 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:40:14 +0800 Subject: [PATCH 060/116] Bump PySwitchBot to 0.60.0 (#142905) update pyswitchbot ver --- 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 3c68facf1e9..f8887f93384 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.59.0"] + "requirements": ["PySwitchbot==0.60.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5b902ce7ad8..48d2f2076fc 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.59.0 +PySwitchbot==0.60.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d3ce9687f3..9649f09da44 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.59.0 +PySwitchbot==0.60.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From 9bff86e7aab1cc28ecfa51c2153010eea03912b7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 14 Apr 2025 09:41:07 +0200 Subject: [PATCH 061/116] Bump pyOverkiz to 1.17.0 (#142854) Bump pyoverkiz to 1.17.0 --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 937b4ccb937..7f4be56979a 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.5"], + "requirements": ["pyoverkiz==1.17.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 48d2f2076fc..c3c53614db4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2214,7 +2214,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.5 +pyoverkiz==1.17.0 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9649f09da44..df194a11038 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1811,7 +1811,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.5 +pyoverkiz==1.17.0 # homeassistant.components.onewire pyownet==0.10.0.post1 From bfc30802921fb3389b298d7ee14d371b104127c0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 09:42:05 +0200 Subject: [PATCH 062/116] Use common states for "Low" / "Medium" / "High" in `climate` (#142842) --- homeassistant/components/climate/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 4682419d1e9..298f953d2c7 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -51,9 +51,9 @@ "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", "auto": "Auto", - "low": "Low", - "medium": "Medium", - "high": "High", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", "top": "Top", "middle": "Middle", "focus": "Focus", From 8bcc4f4c825ec305cb7ffa96897b9ffe000e80f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 21:49:21 -1000 Subject: [PATCH 063/116] Avoid setting up ESPHome dashboard if its been uninstalled (#142904) * Avoid setting up ESPHome dashboard if its been uninstalled * tweaks * coverage * coverage * fix --- homeassistant/components/esphome/dashboard.py | 22 ++++++++-- .../components/esphome/manifest.json | 2 +- tests/components/esphome/test_dashboard.py | 44 +++++++++++++++++-- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 290feec1e2a..bbe4698f278 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util.hass_dict import HassKey @@ -60,11 +61,26 @@ class ESPHomeDashboardManager: async def async_setup(self) -> None: """Restore the dashboard from storage.""" self._data = await self._store.async_load() - if (data := self._data) and (info := data.get("info")): - await self.async_set_dashboard_info( - info["addon_slug"], info["host"], info["port"] + if not (data := self._data) or not (info := data.get("info")): + return + if is_hassio(self._hass): + from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel + get_addons_info, ) + if (addons := get_addons_info(self._hass)) is not None and info[ + "addon_slug" + ] not in addons: + # The addon is not installed anymore, but it make come back + # so we don't want to remove the dashboard, but for now + # we don't want to use it. + _LOGGER.debug("Addon %s is no longer installed", info["addon_slug"]) + return + + await self.async_set_dashboard_info( + info["addon_slug"], info["host"], info["port"] + ) + @callback def async_get(self) -> ESPHomeDashboardCoordinator | None: """Get the current dashboard.""" diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b82d90b10e5..84b7472ad2b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -1,7 +1,7 @@ { "domain": "esphome", "name": "ESPHome", - "after_dependencies": ["zeroconf", "tag"], + "after_dependencies": ["hassio", "zeroconf", "tag"], "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 1641804e458..c3913c3ba9b 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import patch from aioesphomeapi import DeviceInfo, InvalidAuthAPIError +import pytest from homeassistant.components.esphome import CONF_NOISE_PSK, coordinator, dashboard from homeassistant.config_entries import ConfigEntryState @@ -63,15 +64,52 @@ async def test_restore_dashboard_storage_end_to_end( "key": dashboard.STORAGE_KEY, "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, } - with patch( - "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" - ) as mock_dashboard_api: + with ( + patch( + "homeassistant.components.esphome.dashboard.is_hassio", return_value=False + ), + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" + ) as mock_dashboard_api, + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052" +async def test_restore_dashboard_storage_skipped_if_addon_uninstalled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Restore dashboard restore is skipped if the addon is uninstalled.""" + hass_storage[dashboard.STORAGE_KEY] = { + "version": dashboard.STORAGE_VERSION, + "minor_version": dashboard.STORAGE_VERSION, + "key": dashboard.STORAGE_KEY, + "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, + } + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" + ) as mock_dashboard_api, + patch( + "homeassistant.components.esphome.dashboard.is_hassio", return_value=True + ), + patch( + "homeassistant.components.hassio.get_addons_info", + return_value={}, + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + assert "test-slug is no longer installed" in caplog.text + assert not mock_dashboard_api.called + + async def test_setup_dashboard_fails( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 1e31e2944bf2006b8c08c5576d615bcf0a802e1a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Apr 2025 09:50:29 +0200 Subject: [PATCH 064/116] Add parallel updates to UptimeRobot (#142849) --- homeassistant/components/uptimerobot/binary_sensor.py | 3 +++ homeassistant/components/uptimerobot/sensor.py | 3 +++ homeassistant/components/uptimerobot/switch.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 0ad39a5b2c0..f35c7bd87bd 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -14,6 +14,9 @@ from .const import DOMAIN from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 1f1db8844e6..2c0b77fcc20 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -23,6 +23,9 @@ SENSORS_INFO = { 9: "down", } +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index edd93d06e0b..a527bf8ec9b 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -18,6 +18,9 @@ from .const import API_ATTR_OK, DOMAIN, LOGGER from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity +# Limit the number of parallel updates to 1 +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, From 27505359289191379a9e20970f1ea97f7bfd5a3e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Apr 2025 09:51:06 +0200 Subject: [PATCH 065/116] Use runtime_data in UptimeRobot (#142848) * Use runtime_data in UptimeRobot * fix unload --- homeassistant/components/uptimerobot/__init__.py | 13 +++++-------- .../components/uptimerobot/binary_sensor.py | 5 ++--- homeassistant/components/uptimerobot/diagnostics.py | 5 ++--- homeassistant/components/uptimerobot/sensor.py | 5 ++--- homeassistant/components/uptimerobot/switch.py | 6 +++--- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 7bf990489e6..e5829882200 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -9,13 +9,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS +from .const import PLATFORMS from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) -> bool: """Set up UptimeRobot from a config entry.""" - hass.data.setdefault(DOMAIN, {}) key: str = entry.data[CONF_API_KEY] if key.startswith(("ur", "m")): raise ConfigEntryAuthFailed( @@ -23,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) ) uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass)) - hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator( + coordinator = UptimeRobotDataUpdateCoordinator( hass, entry, api=uptime_robot_api, @@ -31,6 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -40,8 +41,4 @@ async def async_unload_entry( hass: HomeAssistant, entry: UptimeRobotConfigEntry ) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index f35c7bd87bd..f14d6d93d71 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -10,8 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity # Coordinator is used to centralize the data updates @@ -24,7 +23,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot binary_sensors.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotBinarySensor( coordinator, diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py index b159d6ddba9..c3c2acbfbf1 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -8,8 +8,7 @@ from pyuptimerobot import UptimeRobotException from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry async def async_get_config_entry_diagnostics( @@ -17,7 +16,7 @@ async def async_get_config_entry_diagnostics( entry: UptimeRobotConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data account: dict[str, Any] | str | None = None try: response = await coordinator.api.async_get_account_details() diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 2c0b77fcc20..3ed97d17508 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -11,8 +11,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity SENSORS_INFO = { @@ -33,7 +32,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot sensors.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotSensor( coordinator, diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index a527bf8ec9b..9b25570393a 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -14,8 +14,8 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import API_ATTR_OK, DOMAIN, LOGGER -from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator +from .const import API_ATTR_OK, LOGGER +from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity # Limit the number of parallel updates to 1 @@ -28,7 +28,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot switches.""" - coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( UptimeRobotSwitch( coordinator, From 6d5c000e1fce94bc4ab5dcf453bde9a646a52737 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Apr 2025 09:51:41 +0200 Subject: [PATCH 066/116] Set entity categories for some entities in Syncthru (#142828) Set entity categories for some entities --- homeassistant/components/syncthru/sensor.py | 6 +++++- tests/components/syncthru/snapshots/test_sensor.ambr | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 7896b275f45..569bf65f37d 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -9,7 +9,7 @@ from typing import Any, cast from pysyncthru import SyncThru, SyncthruState from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -40,6 +40,7 @@ def get_toner_entity_description(color: str) -> SyncThruSensorDescription: return SyncThruSensorDescription( key=f"toner_{color}", translation_key=f"toner_{color}", + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, value_fn=lambda printer: printer.toner_status().get(color, {}).get("remaining"), extra_state_attributes_fn=lambda printer: printer.toner_status().get(color, {}), @@ -51,6 +52,7 @@ def get_drum_entity_description(color: str) -> SyncThruSensorDescription: return SyncThruSensorDescription( key=f"drum_{color}", translation_key=f"drum_{color}", + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, value_fn=lambda printer: printer.drum_status().get(color, {}).get("remaining"), extra_state_attributes_fn=lambda printer: printer.drum_status().get(color, {}), @@ -68,6 +70,7 @@ def get_input_tray_entity_description(tray: str) -> SyncThruSensorDescription: return SyncThruSensorDescription( key=f"tray_{tray}", translation_key=translation_key, + entity_category=EntityCategory.DIAGNOSTIC, translation_placeholders=placeholders, value_fn=( lambda printer: printer.input_tray_status().get(tray, {}).get("newError") @@ -84,6 +87,7 @@ def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription: return SyncThruSensorDescription( key=f"output_tray_{tray}", translation_key="output_tray", + entity_category=EntityCategory.DIAGNOSTIC, translation_placeholders={"tray_number": str(tray)}, value_fn=( lambda printer: printer.output_tray_status().get(tray, {}).get("status") diff --git a/tests/components/syncthru/snapshots/test_sensor.ambr b/tests/components/syncthru/snapshots/test_sensor.ambr index 87e96a5cc53..b7edc046879 100644 --- a/tests/components/syncthru/snapshots/test_sensor.ambr +++ b/tests/components/syncthru/snapshots/test_sensor.ambr @@ -109,7 +109,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.sec84251907c415_black_toner_level', 'has_entity_name': True, 'hidden_by': None, @@ -162,7 +162,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.sec84251907c415_cyan_toner_level', 'has_entity_name': True, 'hidden_by': None, @@ -215,7 +215,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.sec84251907c415_input_tray_1', 'has_entity_name': True, 'hidden_by': None, @@ -271,7 +271,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.sec84251907c415_magenta_toner_level', 'has_entity_name': True, 'hidden_by': None, @@ -324,7 +324,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.sec84251907c415_output_tray_1', 'has_entity_name': True, 'hidden_by': None, @@ -375,7 +375,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.sec84251907c415_yellow_toner_level', 'has_entity_name': True, 'hidden_by': None, From 8767599ad4c9cd72886f923656a395af802cfbd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 22:02:46 -1000 Subject: [PATCH 067/116] Validate ESPHome mac address before updating IP on discovery (#142878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump aioesphomeapi to 29.10.0 changelog: https://github.com/esphome/aioesphomeapi/compare/v29.9.0...v29.10.0 * Validate ESPHome mac address before updating IP on discovery In some cases the data coming in from discovery may be stale since there is a small race window if devices get new IP allocations. Since some routers do not update their names right away and zeroconf has a non-zero TTL there is a small window where the discovery data can be stale. This is a rare condition but it does happen. With aioesphomeapi 29.10.0+ and ESPHome 2025.4.x+ we can validate the mac address even without the correct encryption key which allows us to be able to always validate the MAC before updating the IP from any discovery method. * tweaks * fix test --- .../components/esphome/config_flow.py | 59 +++++++++--- tests/components/esphome/test_config_flow.py | 91 +++++++++++++++++++ tests/components/esphome/test_manager.py | 2 +- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 686d77d9b34..52b8514088a 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -74,6 +74,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._device_info: DeviceInfo | None = None # The ESPHome name as per its config self._device_name: str | None = None + self._device_mac: str | None = None async def _async_step_user_base( self, user_input: dict[str, Any] | None = None, error: str | None = None @@ -265,12 +266,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Check if already configured await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured( - updates={CONF_HOST: self._host, CONF_PORT: self._port} + await self._async_validate_mac_abort_configured( + mac_address, self._host, self._port ) return await self.async_step_discovery_confirm() + async def _async_validate_mac_abort_configured( + self, formatted_mac: str, host: str, port: int | None + ) -> None: + """Validate if the MAC address is already configured.""" + if not ( + entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, formatted_mac + ) + ): + return + configured_port: int | None = entry.data.get(CONF_PORT) + configured_psk: str | None = entry.data.get(CONF_NOISE_PSK) + await self._fetch_device_info(host, port or configured_port, configured_psk) + updates: dict[str, Any] = {} + if self._device_mac == formatted_mac: + updates[CONF_HOST] = host + if port is not None: + updates[CONF_PORT] = port + self._abort_if_unique_id_configured(updates=updates) + async def async_step_mqtt( self, discovery_info: MqttServiceInfo ) -> ConfigFlowResult: @@ -314,8 +335,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" - await self.async_set_unique_id(format_mac(discovery_info.macaddress)) - self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + mac_address = format_mac(discovery_info.macaddress) + await self.async_set_unique_id(format_mac(mac_address)) + await self._async_validate_mac_abort_configured( + mac_address, discovery_info.ip, None + ) # This should never happen since we only listen to DHCP requests # for configured devices. return self.async_abort(reason="already_configured") @@ -398,17 +422,17 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def fetch_device_info(self) -> str | None: + async def _fetch_device_info( + self, host: str, port: int | None, noise_psk: str | None + ) -> str | None: """Fetch device info from API and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) - assert self._host is not None - assert self._port is not None cli = APIClient( - self._host, - self._port, + host, + port or 6053, "", zeroconf_instance=zeroconf_instance, - noise_psk=self._noise_psk, + noise_psk=noise_psk, ) try: @@ -419,6 +443,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): except InvalidEncryptionKeyAPIError as ex: if ex.received_name: self._device_name = ex.received_name + if ex.received_mac: + self._device_mac = format_mac(ex.received_mac) self._name = ex.received_name return ERROR_INVALID_ENCRYPTION_KEY except ResolveAPIError: @@ -427,9 +453,20 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return "connection_error" finally: await cli.disconnect(force=True) - self._name = self._device_info.friendly_name or self._device_info.name self._device_name = self._device_info.name + self._device_mac = format_mac(self._device_info.mac_address) + return None + + async def fetch_device_info(self) -> str | None: + """Fetch device info from API and return any errors.""" + assert self._host is not None + assert self._port is not None + if error := await self._fetch_device_info( + self._host, self._port, self._noise_psk + ): + return error + assert self._device_info is not None mac_address = format_mac(self._device_info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) if self.source != SOURCE_REAUTH: diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index d48a1f40482..60c93d5fb2c 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1087,6 +1087,9 @@ async def test_discovery_dhcp_updates_host( unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(name="test8266", mac_address="1122334455aa") + ) service_info = DhcpServiceInfo( ip="192.168.43.184", @@ -1103,6 +1106,94 @@ async def test_discovery_dhcp_updates_host( assert entry.data[CONF_HOST] == "192.168.43.184" +async def test_discovery_dhcp_does_not_update_host_wrong_mac( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: + """Test dhcp discovery does not update the host if the mac is wrong.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(name="test8266", mac_address="1122334455ff") + ) + + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Mac was wrong, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: + """Test dhcp discovery does not update the host if the mac is wrong.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError( + "Wrong key", "test8266", "1122334455cc" + ) + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Mac was wrong, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: + """Test dhcp discovery does not update the host if the mac is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError( + "Wrong key", "test8266", None + ) + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Mac was missing, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + async def test_discovery_dhcp_no_changes( hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 905a3f6bdc7..f4cae1e1499 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -742,7 +742,7 @@ async def test_connection_aborted_wrong_device( assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() - assert len(new_info.mock_calls) == 1 + assert len(new_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text From db043b26da63958e756e1d423fd1b9e40636d26e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 14 Apr 2025 01:05:34 -0700 Subject: [PATCH 068/116] Fix quality loss for LLM conversation agent question answering (#142873) * Fix a bug parsing a streaming response with no json * Remove debug lines * Fix quality loss for LLM conversation agent question answering * Update tests --- homeassistant/helpers/llm.py | 32 ++++++++++++++++++++++++++------ tests/helpers/test_llm.py | 28 ++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 24062ba1521..3e521aa7ef1 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -72,6 +72,19 @@ NO_ENTITIES_PROMPT = ( "to their voice assistant in Home Assistant." ) +DYNAMIC_CONTEXT_PROMPT = """You ARE equipped to answer questions about the current state of +the home using the `GetLiveContext` tool. This is a primary function. Do not state you lack the +functionality if the question requires live data. +If the user asks about device existence/type (e.g., "Do I have lights in the bedroom?"): Answer +from the static context below. +If the user asks about the CURRENT state, value, or mode (e.g., "Is the lock locked?", +"Is the fan on?", "What mode is the thermostat in?", "What is the temperature outside?"): + 1. Recognize this requires live data. + 2. You MUST call `GetLiveContext`. This tool will provide the needed real-time information (like temperature from the local weather, lock status, etc.). + 3. Use the tool's response** to answer the user accurately (e.g., "The temperature outside is [value from tool]."). +For general knowledge questions not about the home: Answer truthfully from internal knowledge. +""" + @callback def async_render_no_api_prompt(hass: HomeAssistant) -> str: @@ -495,6 +508,8 @@ class AssistAPI(API): ): prompt.append("This device is not able to start timers.") + prompt.append(DYNAMIC_CONTEXT_PROMPT) + return prompt @callback @@ -506,7 +521,7 @@ class AssistAPI(API): if exposed_entities and exposed_entities["entities"]: prompt.append( - "An overview of the areas and the devices in this smart home:" + "Static Context: An overview of the areas and the devices in this smart home:" ) prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) @@ -568,7 +583,7 @@ class AssistAPI(API): ) if exposed_domains: - tools.append(GetHomeStateTool()) + tools.append(GetLiveContextTool()) return tools @@ -1009,7 +1024,7 @@ class CalendarGetEventsTool(Tool): return {"success": True, "result": events} -class GetHomeStateTool(Tool): +class GetLiveContextTool(Tool): """Tool for getting the current state of exposed entities. This returns state for all entities that have been exposed to @@ -1017,8 +1032,13 @@ class GetHomeStateTool(Tool): returns state for entities based on intent parameters. """ - name = "get_home_state" - description = "Get the current state of all devices in the home. " + name = "GetLiveContext" + description = ( + "Use this tool when the user asks a question about the CURRENT state, " + "value, or mode of a specific device, sensor, entity, or area in the " + "smart home, and the answer can be improved with real-time data not " + "available in the static device overview list. " + ) async def async_call( self, @@ -1036,7 +1056,7 @@ class GetHomeStateTool(Tool): 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:", + "Live Context: An overview of the areas and the devices in this smart home:", yaml_util.dump(list(exposed_entities["entities"].values())), ] return { diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 23c2eef1765..145618cbeab 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -185,13 +185,13 @@ async def test_assist_api( assert len(llm.async_get_apis(hass)) == 1 api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == ["get_home_state"] + assert [tool.name for tool in api.tools] == ["GetLiveContext"] # Match all intent_handler.platforms = None api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == ["test_intent", "get_home_state"] + assert [tool.name for tool in api.tools] == ["test_intent", "GetLiveContext"] # Match specific domain intent_handler.platforms = {"light"} @@ -579,7 +579,7 @@ async def test_assist_api_prompt( suggested_area="Test Area 2", ) ) - exposed_entities_prompt = """An overview of the areas and the devices in this smart home: + exposed_entities_prompt = """Live Context: An overview of the areas and the devices in this smart home: - names: '1' domain: light state: unavailable @@ -627,7 +627,7 @@ async def test_assist_api_prompt( state: unavailable areas: Test Area 2 """ - stateless_exposed_entities_prompt = """An overview of the areas and the devices in this smart home: + stateless_exposed_entities_prompt = """Static Context: An overview of the areas and the devices in this smart home: - names: '1' domain: light areas: Test Area 2 @@ -673,17 +673,30 @@ async def test_assist_api_prompt( "When a user asks to turn on all devices of a specific type, " "ask user to specify an area, unless there is only one device of that type." ) + dynamic_context_prompt = """You ARE equipped to answer questions about the current state of +the home using the `GetLiveContext` tool. This is a primary function. Do not state you lack the +functionality if the question requires live data. +If the user asks about device existence/type (e.g., "Do I have lights in the bedroom?"): Answer +from the static context below. +If the user asks about the CURRENT state, value, or mode (e.g., "Is the lock locked?", +"Is the fan on?", "What mode is the thermostat in?", "What is the temperature outside?"): + 1. Recognize this requires live data. + 2. You MUST call `GetLiveContext`. This tool will provide the needed real-time information (like temperature from the local weather, lock status, etc.). + 3. Use the tool's response** to answer the user accurately (e.g., "The temperature outside is [value from tool]."). +For general knowledge questions not about the home: Answer truthfully from internal knowledge. +""" api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} +{dynamic_context_prompt} {stateless_exposed_entities_prompt}""" ) - # Verify that the get_home_state tool returns the same results as the exposed_entities_prompt + # Verify that the GetLiveContext 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={}) + llm.ToolInput(tool_name="GetLiveContext", tool_args={}) ) assert result == { "success": True, @@ -701,6 +714,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} +{dynamic_context_prompt} {stateless_exposed_entities_prompt}""" ) @@ -716,6 +730,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} +{dynamic_context_prompt} {stateless_exposed_entities_prompt}""" ) @@ -727,6 +742,7 @@ async def test_assist_api_prompt( assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} +{dynamic_context_prompt} {stateless_exposed_entities_prompt}""" ) From a340646e1ee26d0668ac5884dd827b9375e1188f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 22:10:07 -1000 Subject: [PATCH 069/116] Avoid starting ESPHome reauth when an unexpected device is found at the last address (#142814) * Bump aioesphomeapi to 29.10.0 changelog: https://github.com/esphome/aioesphomeapi/compare/v29.9.0...v29.10.0 * Avoid starting ESPHome reauth when an unexpected device is found at the last address fixes #133956 * coverage --- homeassistant/components/esphome/manager.py | 33 ++++++++++++++++-- tests/components/esphome/test_manager.py | 38 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 56c2998a3cc..e119d152d09 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -568,7 +568,7 @@ class ESPHomeManager: async def on_connect_error(self, err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" - if isinstance( + if not isinstance( err, ( EncryptionPlaintextAPIError, @@ -577,7 +577,36 @@ class ESPHomeManager: InvalidAuthAPIError, ), ): - self.entry.async_start_reauth(self.hass) + return + if isinstance(err, InvalidEncryptionKeyAPIError): + if ( + (received_name := err.received_name) + and (received_mac := err.received_mac) + and (unique_id := self.entry.unique_id) + and ":" in unique_id + ): + formatted_received_mac = format_mac(received_mac) + formatted_expected_mac = format_mac(unique_id) + if formatted_received_mac != formatted_expected_mac: + _LOGGER.error( + "Unexpected device found at %s; " + "expected `%s` with mac address `%s`, " + "found `%s` with mac address `%s`", + self.host, + self.entry.data.get(CONF_DEVICE_NAME), + formatted_expected_mac, + received_name, + formatted_received_mac, + ) + # If the device comes back online, discovery + # will update the config entry with the new IP address + # and reload which will try again to connect to the device. + # In the mean time we stop the reconnect logic + # so we don't keep trying to connect to the wrong device. + if self.reconnect_logic: + await self.reconnect_logic.stop() + return + self.entry.async_start_reauth(self.hass) @callback def _async_handle_logging_changed(self, _event: Event) -> None: diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index f4cae1e1499..02a32190437 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -9,6 +9,7 @@ from aioesphomeapi import ( APIClient, APIConnectionError, DeviceInfo, + EncryptionPlaintextAPIError, EntityInfo, EntityState, HomeassistantServiceCall, @@ -1316,6 +1317,7 @@ async def test_disconnects_at_close_event( @pytest.mark.parametrize( "error", [ + EncryptionPlaintextAPIError, RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError, InvalidAuthAPIError, @@ -1349,6 +1351,42 @@ async def test_start_reauth( assert flow["context"]["source"] == "reauth" +async def test_no_reauth_wrong_mac( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exceptions on connect error trigger reauth.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"compilation_time": "comp_time"}, + states=[], + ) + await hass.async_block_till_done() + + await device.mock_connect_error( + InvalidEncryptionKeyAPIError( + "fail", received_mac="aabbccddeeff", received_name="test" + ) + ) + await hass.async_block_till_done() + + # Reauth should not be triggered + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 0 + assert ( + "Unexpected device found at test.local; expected `test` " + "with mac address `11:22:33:44:55:aa`, found `test` " + "with mac address `aa:bb:cc:dd:ee:ff`" in caplog.text + ) + + async def test_entry_missing_unique_id( hass: HomeAssistant, mock_client: APIClient, From 1aa996d5f0fc5e120da00731e4b2e782c265e100 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 22:12:27 -1000 Subject: [PATCH 070/116] Add debug logging to homekit when an sensor entity cannot be classified (#142707) * Add debug logging to homekit when an sensor entity cannot be classified In #132937 many hours were spent investigating an issue which turned out to be that the entity did not have a device class at startup because the group integration does not set the device class if any of the underlying entities state is invalid. closes #132937 * coverage * Update tests/components/homekit/test_get_accessories.py --- homeassistant/components/homekit/accessories.py | 7 +++++++ tests/components/homekit/test_get_accessories.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index d680181f5e4..95842d56094 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -245,6 +245,13 @@ def get_accessory( # noqa: C901 a_type = "CarbonDioxideSensor" elif device_class == SensorDeviceClass.ILLUMINANCE or unit == LIGHT_LUX: a_type = "LightSensor" + else: + _LOGGER.debug( + "%s: Unsupported sensor type (device_class=%s) (unit=%s)", + state.entity_id, + device_class, + unit, + ) elif state.domain == "switch": if switch_type := config.get(CONF_TYPE): diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 56208961312..de5cda71513 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -53,6 +53,12 @@ def test_not_supported(caplog: pytest.LogCaptureFixture) -> None: assert "invalid aid" in caplog.records[0].msg +def test_not_supported_sensor(caplog: pytest.LogCaptureFixture) -> None: + """Test if none is returned if entity isn't supported.""" + assert get_accessory(None, None, State("sensor.xyz", "on"), 2, {}) is None + assert "Unsupported sensor type (device_class=None)" in caplog.text + + def test_not_supported_media_player() -> None: """Test if mode isn't supported and if no supported modes.""" # selected mode for entity not supported From 6f02550ac34cd71968f9abf9ab9dc43402659df0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 22:14:48 -1000 Subject: [PATCH 071/116] Include HKC BLE MAC in device info when available (#141900) * Include HKC BLE MAC in device info when available * update tests * cover * dry * dry * dry --- .../homekit_controller/connection.py | 14 +++- .../components/homekit_controller/conftest.py | 32 +++++++- .../homekit_controller/test_init.py | 29 ++++++++ .../homekit_controller/test_sensor.py | 74 +++++++++---------- 4 files changed, 106 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 43cbdec67fa..931bd40d64c 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -9,10 +9,11 @@ from functools import partial import logging from operator import attrgetter from types import MappingProxyType -from typing import Any +from typing import Any, cast from aiohomekit import Controller from aiohomekit.controller import TransportType +from aiohomekit.controller.ble.discovery import BleDiscovery from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, @@ -372,6 +373,16 @@ class HKDevice: if not self.unreliable_serial_numbers: identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number)) + connections: set[tuple[str, str]] = set() + if self.pairing.transport == Transport.BLE and ( + discovery := self.pairing.controller.discoveries.get( + normalize_hkid(self.unique_id) + ) + ): + connections = { + (dr.CONNECTION_BLUETOOTH, cast(BleDiscovery, discovery).device.address), + } + device_info = DeviceInfo( identifiers={ ( @@ -379,6 +390,7 @@ class HKDevice: f"{self.unique_id}:aid:{accessory.aid}", ) }, + connections=connections, name=accessory.name, manufacturer=accessory.manufacturer, model=accessory.model, diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 4e787f305b6..882d0d60e66 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -4,7 +4,9 @@ from collections.abc import Callable, Generator import datetime from unittest.mock import MagicMock, patch -from aiohomekit.testing import FakeController +from aiohomekit.model import Transport +from aiohomekit.testing import FakeController, FakeDiscovery, FakePairing +from bleak.backends.device import BLEDevice from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -57,3 +59,31 @@ def get_next_aid() -> Generator[Callable[[], int]]: return id_counter return _get_id + + +@pytest.fixture +def fake_ble_discovery() -> Generator[None]: + """Fake BLE discovery.""" + + class FakeBLEDiscovery(FakeDiscovery): + device = BLEDevice( + address="AA:BB:CC:DD:EE:FF", name="TestDevice", rssi=-50, details=() + ) + + with patch("aiohomekit.testing.FakeDiscovery", FakeBLEDiscovery): + yield + + +@pytest.fixture +def fake_ble_pairing() -> Generator[None]: + """Fake BLE pairing.""" + + class FakeBLEPairing(FakePairing): + """Fake BLE pairing.""" + + @property + def transport(self): + return Transport.BLE + + with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): + yield diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index f74e8ea994e..656978a08a2 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -174,6 +174,7 @@ async def test_offline_device_raises( assert hass.states.get("light.testdevice").state == STATE_OFF +@pytest.mark.usefixtures("fake_ble_discovery") async def test_ble_device_only_checks_is_available( hass: HomeAssistant, get_next_aid: Callable[[], int], controller ) -> None: @@ -242,6 +243,34 @@ async def test_ble_device_only_checks_is_available( assert hass.states.get("light.testdevice").state == STATE_OFF +@pytest.mark.usefixtures("fake_ble_discovery", "fake_ble_pairing") +async def test_ble_device_populates_connections( + hass: HomeAssistant, get_next_aid: Callable[[], int], controller +) -> None: + """Test a BLE device populates connections in the device registry.""" + aid = get_next_aid() + + accessory = Accessory.create_with_info( + aid, "TestDevice", "example.com", "Test", "0001", "0.1" + ) + create_alive_service(accessory) + + await async_setup_component(hass, DOMAIN, {}) + config_entry, _ = await setup_test_accessories_with_controller( + hass, [accessory], controller + ) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + dev_reg = dr.async_get(hass) + assert ( + dev_reg.async_get_device( + identifiers={}, connections={("bluetooth", "AA:BB:CC:DD:EE:FF")} + ) + is not None + ) + + @pytest.mark.parametrize("example", FIXTURES, ids=lambda val: str(val.stem)) async def test_snapshots( hass: HomeAssistant, diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index c40864c9629..3c8618c66c5 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -1,14 +1,12 @@ """Basic checks for HomeKit sensor.""" from collections.abc import Callable -from unittest.mock import patch -from aiohomekit.model import Accessory, Transport +from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus from aiohomekit.model.services import Service, ServicesTypes from aiohomekit.protocol.statuscodes import HapStatusCode -from aiohomekit.testing import FakePairing import pytest from homeassistant.components.homekit_controller.sensor import ( @@ -406,34 +404,36 @@ def test_thread_status_to_str() -> None: assert thread_status_to_str(ThreadStatus.DISABLED) == "disabled" -@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures( + "enable_bluetooth", + "entity_registry_enabled_by_default", + "fake_ble_discovery", + "fake_ble_pairing", +) async def test_rssi_sensor( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: """Test an rssi sensor.""" inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) - class FakeBLEPairing(FakePairing): - """Fake BLE pairing.""" - - @property - def transport(self): - return Transport.BLE - - with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): - # Any accessory will do for this test, but we need at least - # one or the rssi sensor will not be created - await setup_test_component( - hass, - get_next_aid(), - create_battery_level_sensor, - suffix="battery", - connection="BLE", - ) - assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" + # Any accessory will do for this test, but we need at least + # one or the rssi sensor will not be created + await setup_test_component( + hass, + get_next_aid(), + create_battery_level_sensor, + suffix="battery", + connection="BLE", + ) + assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" -@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures( + "enable_bluetooth", + "entity_registry_enabled_by_default", + "fake_ble_discovery", + "fake_ble_pairing", +) async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -449,24 +449,16 @@ async def test_migrate_rssi_sensor_unique_id( inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) - class FakeBLEPairing(FakePairing): - """Fake BLE pairing.""" - - @property - def transport(self): - return Transport.BLE - - with patch("aiohomekit.testing.FakePairing", FakeBLEPairing): - # Any accessory will do for this test, but we need at least - # one or the rssi sensor will not be created - await setup_test_component( - hass, - get_next_aid(), - create_battery_level_sensor, - suffix="battery", - connection="BLE", - ) - assert hass.states.get("sensor.renamed_rssi").state == "-56" + # Any accessory will do for this test, but we need at least + # one or the rssi sensor will not be created + await setup_test_component( + hass, + get_next_aid(), + create_battery_level_sensor, + suffix="battery", + connection="BLE", + ) + assert hass.states.get("sensor.renamed_rssi").state == "-56" assert ( entity_registry.async_get(rssi_sensor.entity_id).unique_id From 422bcecec14ade3686915acc5f9328616e3d1611 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Apr 2025 10:18:33 +0200 Subject: [PATCH 072/116] Add quality scale to Comelit (#139743) * Add quality scale to Comelit * tweek * updates * update * update manifest * tweak * update after latest merges * update quality scale * tweak * apply review comments * apply review comment * one more review comment --- .../components/comelit/quality_scale.yaml | 94 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/comelit/quality_scale.yaml diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml new file mode 100644 index 00000000000..9c4aab049d1 --- /dev/null +++ b/homeassistant/components/comelit/quality_scale.yaml @@ -0,0 +1,94 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions + appropriate-polling: done + brands: done + common-modules: + status: todo + comment: PR in progress + 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: wrap api calls in try block + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: no configuration parameters + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: device not discoverable + discovery: + status: exempt + comment: device not discoverable + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: no known limitations, yet + docs-supported-devices: + status: todo + comment: review and complete missing ones + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: todo + comment: missing implementation + entity-category: + status: todo + comment: PR in progress + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: todo + comment: PR in progress + icon-translations: done + reconfiguration-flow: + status: todo + comment: PR in progress + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: todo + comment: missing implementation + + # Platinum + async-dependency: done + inject-websession: + status: todo + comment: implement aiohttp_client.async_create_clientsession + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index c122856ab5c..f1ab244e30a 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -256,7 +256,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "coinbase", "color_extractor", "comed_hourly_pricing", - "comelit", "comfoconnect", "command_line", "compensation", From 9239ace1c8fb346b95e5a5665d01cc6e35a4b7f5 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 14 Apr 2025 11:24:01 +0300 Subject: [PATCH 073/116] Config flow progress in percent (#142737) * Config flow progress in percent * PR comments --- homeassistant/data_entry_flow.py | 9 +++++++++ tests/test_data_entry_flow.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e2e31ffce29..9286f9c78f5 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -40,6 +40,7 @@ class FlowResultType(StrEnum): # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" +EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE = "data_entry_flow_progress_update" FLOW_NOT_COMPLETE_STEPS = { FlowResultType.FORM, @@ -829,6 +830,14 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow_result["step_id"] = step_id return flow_result + @callback + def async_update_progress(self, progress: float) -> None: + """Update the progress of a flow. `progress` must be between 0 and 1.""" + self.hass.bus.async_fire_internal( + EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE, + {"handler": self.handler, "flow_id": self.flow_id, "progress": progress}, + ) + @callback def async_show_progress_done(self, *, next_step_id: str) -> _FlowResultT: """Mark the progress done.""" diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 804b1fea405..961afd69c2d 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -464,6 +464,9 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N """Test show progress logic.""" manager.hass = hass events = [] + progress_update_events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE + ) task_one_evt = asyncio.Event() task_two_evt = asyncio.Event() event_received_evt = asyncio.Event() @@ -486,7 +489,9 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N await task_one_evt.wait() async def long_running_job_two() -> None: + self.async_update_progress(0.25) await task_two_evt.wait() + self.async_update_progress(0.75) self.data = {"title": "Hello"} uncompleted_task: asyncio.Task[None] | None = None @@ -545,6 +550,12 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N result = await manager.async_configure(result["flow_id"]) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "task_two" + assert len(progress_update_events) == 1 + assert progress_update_events[0].data == { + "handler": "test", + "flow_id": result["flow_id"], + "progress": 0.25, + } # Set task two done and wait for event task_two_evt.set() @@ -556,6 +567,12 @@ async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> N "flow_id": result["flow_id"], "refresh": True, } + assert len(progress_update_events) == 2 + assert progress_update_events[1].data == { + "handler": "test", + "flow_id": result["flow_id"], + "progress": 0.75, + } # Frontend refreshes the flow result = await manager.async_configure(result["flow_id"]) From a6643d8fb38a429eda82e8b2a99cf34d2e5309ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 22:31:38 -1000 Subject: [PATCH 074/116] Add support for InkBird IAM-T1 (#142824) --- homeassistant/components/inkbird/__init__.py | 16 ++- .../components/inkbird/config_flow.py | 15 ++- homeassistant/components/inkbird/const.py | 1 + .../components/inkbird/coordinator.py | 39 +++++- .../components/inkbird/manifest.json | 9 ++ homeassistant/components/inkbird/sensor.py | 14 +++ homeassistant/components/inkbird/strings.json | 5 + homeassistant/generated/bluetooth.py | 15 +++ tests/components/inkbird/__init__.py | 11 ++ tests/components/inkbird/test_config_flow.py | 10 +- tests/components/inkbird/test_sensor.py | 112 ++++++++++++++++-- 11 files changed, 219 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index bc81b852f02..8daa94f2f6d 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -2,25 +2,29 @@ from __future__ import annotations -from inkbird_ble import INKBIRDBluetoothDeviceData +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TYPE +from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] - INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator] +PLATFORMS: list[Platform] = [Platform.SENSOR] + async def async_setup_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" + assert entry.unique_id is not None device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) - data = INKBIRDBluetoothDeviceData(device_type) - coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data) + device_data: dict[str, Any] | None = entry.data.get(CONF_DEVICE_DATA) + coordinator = INKBIRDActiveBluetoothProcessorCoordinator( + hass, entry, device_type, device_data + ) + await coordinator.async_init() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe diff --git a/homeassistant/components/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py index 09dd31a9cf6..9ce20baaeda 100644 --- a/homeassistant/components/inkbird/config_flow.py +++ b/homeassistant/components/inkbird/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import CONF_DEVICE_TYPE, DOMAIN class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): @@ -26,7 +26,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_device: DeviceData | None = None - self._discovered_devices: dict[str, str] = {} + self._discovered_devices: dict[str, tuple[str, str]] = {} async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -51,7 +51,10 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info = self._discovery_info title = device.title or device.get_device_name() or discovery_info.name if user_input is not None: - return self.async_create_entry(title=title, data={}) + return self.async_create_entry( + title=title, + data={CONF_DEVICE_TYPE: str(self._discovered_device.device_type)}, + ) self._set_confirm_only() placeholders = {"name": title} @@ -68,8 +71,9 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): address = user_input[CONF_ADDRESS] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() + title, device_type = self._discovered_devices[address] return self.async_create_entry( - title=self._discovered_devices[address], data={} + title=title, data={CONF_DEVICE_TYPE: device_type} ) current_addresses = self._async_current_ids(include_ignore=False) @@ -80,7 +84,8 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN): device = DeviceData() if device.supported(discovery_info): self._discovered_devices[address] = ( - device.title or device.get_device_name() or discovery_info.name + device.title or device.get_device_name() or discovery_info.name, + str(device.device_type), ) if not self._discovered_devices: diff --git a/homeassistant/components/inkbird/const.py b/homeassistant/components/inkbird/const.py index 93fdcc7519c..b20e1af8de1 100644 --- a/homeassistant/components/inkbird/const.py +++ b/homeassistant/components/inkbird/const.py @@ -3,3 +3,4 @@ DOMAIN = "inkbird" CONF_DEVICE_TYPE = "device_type" +CONF_DEVICE_DATA = "device_data" diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py index b119682a7d6..ed55bc79115 100644 --- a/homeassistant/components/inkbird/coordinator.py +++ b/homeassistant/components/inkbird/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Any from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate @@ -12,15 +13,17 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfo, BluetoothServiceInfoBleak, async_ble_device_from_address, + async_last_service_info, ) from homeassistant.components.bluetooth.active_update_processor import ( ActiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_interval -from .const import CONF_DEVICE_TYPE +from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -32,15 +35,19 @@ class INKBIRDActiveBluetoothProcessorCoordinator( ): """Coordinator for INKBIRD Bluetooth devices.""" + _data: INKBIRDBluetoothDeviceData + def __init__( self, hass: HomeAssistant, entry: ConfigEntry, - data: INKBIRDBluetoothDeviceData, + device_type: str | None, + device_data: dict[str, Any] | None, ) -> None: """Initialize the INKBIRD Bluetooth processor coordinator.""" - self._data = data self._entry = entry + self._device_type = device_type + self._device_data = device_data address = entry.unique_id assert address is not None entry.async_on_unload( @@ -58,6 +65,25 @@ class INKBIRDActiveBluetoothProcessorCoordinator( poll_method=self._async_poll_data, ) + async def async_init(self) -> None: + """Initialize the coordinator.""" + self._data = INKBIRDBluetoothDeviceData( + self._device_type, + self._device_data, + self.async_set_updated_data, + self._async_device_data_changed, + ) + if not self._data.uses_notify: + return + if not (service_info := async_last_service_info(self.hass, self.address)): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="no_advertisement", + translation_placeholders={"address": self.address}, + ) + await self._data.async_start(service_info, service_info.device) + self._entry.async_on_unload(self._data.async_stop) + async def _async_poll_data( self, last_service_info: BluetoothServiceInfoBleak ) -> SensorUpdate: @@ -78,6 +104,13 @@ class INKBIRDActiveBluetoothProcessorCoordinator( ) ) + @callback + def _async_device_data_changed(self, new_device_data: dict[str, Any]) -> None: + """Handle device data changed.""" + self.hass.config_entries.async_update_entry( + self._entry, data={**self._entry.data, CONF_DEVICE_DATA: new_device_data} + ) + @callback def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: """Handle update callback from the passive BLE processor.""" diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 2e23663f5ff..76296870846 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -33,6 +33,15 @@ { "local_name": "ITH-21-B", "connectable": false + }, + { + "local_name": "Ink@IAM-T1", + "connectable": true + }, + { + "manufacturer_id": 12628, + "manufacturer_data_start": [65, 67, 45], + "connectable": true } ], "codeowners": ["@bdraco"], diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index 447d7ac961b..c7d80e9bc9f 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -17,8 +17,10 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfPressure, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -56,6 +58,18 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + (DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( + key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + (DeviceClass.PRESSURE, Units.PRESSURE_HPA): SensorEntityDescription( + key=f"{DeviceClass.PRESSURE}_{Units.PRESSURE_HPA}", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + ), } diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 4e12a84b653..b8490dfb92a 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -17,5 +17,10 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "exceptions": { + "no_advertisement": { + "message": "The device with address {address} is not advertising; Make sure it is in range and powered on." + } } } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 1ff444ca25f..da4b21cbba2 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -371,6 +371,21 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "ITH-21-B", }, + { + "connectable": True, + "domain": "inkbird", + "local_name": "Ink@IAM-T1", + }, + { + "connectable": True, + "domain": "inkbird", + "manufacturer_data_start": [ + 65, + 67, + 45, + ], + "manufacturer_id": 12628, + }, { "connectable": True, "domain": "iron_os", diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 63acff7a150..f798fee292c 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -92,3 +92,14 @@ IBBQ_SERVICE_INFO = _make_bluetooth_service_info( service_data={}, source="local", ) + + +IAM_T1_SERVICE_INFO = _make_bluetooth_service_info( + name="Ink@IAM-T1", + manufacturer_data={12628: b"AC-6200a13cae\x00\x00"}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + address="62:00:A1:3C:AE:7B", + rssi=-44, + service_data={}, + source="local", +) diff --git a/tests/components/inkbird/test_config_flow.py b/tests/components/inkbird/test_config_flow.py index 796f57da55b..419bc742479 100644 --- a/tests/components/inkbird/test_config_flow.py +++ b/tests/components/inkbird/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.inkbird.const import DOMAIN +from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,7 +27,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "iBBQ AC3D" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "iBBQ-4"} assert result2["result"].unique_id == "4125DDBA-2774-4851-9889-6AADDD4CAC3D" @@ -71,7 +71,7 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" @@ -101,7 +101,7 @@ async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" @@ -220,7 +220,7 @@ async def test_async_step_user_takes_precedence_over_discovery( ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "IBS-TH 8105" - assert result2["data"] == {} + assert result2["data"] == {CONF_DEVICE_TYPE: "IBS-TH"} assert result2["result"].unique_id == "61DE521B-F0BF-9F44-64D4-75BBE1738105" # Verify the original one was aborted diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 67e08396c79..1feb5f5b02c 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -1,25 +1,35 @@ """Test the INKBIRD config flow.""" -from unittest.mock import patch +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from inkbird_ble import ( DeviceKey, + INKBIRDBluetoothDeviceData, SensorDescription, SensorDeviceInfo, SensorUpdate, SensorValue, Units, ) +from inkbird_ble.parser import Model from sensor_state_data import SensorDeviceClass -from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN +from homeassistant.components.inkbird.const import ( + CONF_DEVICE_DATA, + CONF_DEVICE_TYPE, + DOMAIN, +) from homeassistant.components.inkbird.coordinator import FALLBACK_POLL_INTERVAL from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from . import ( + IAM_T1_SERVICE_INFO, SPS_PASSIVE_SERVICE_INFO, SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO, @@ -29,13 +39,13 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import inject_bluetooth_service_info -def _make_sensor_update(humidity: float) -> SensorUpdate: +def _make_sensor_update(name: str, humidity: float) -> SensorUpdate: return SensorUpdate( title=None, devices={ None: SensorDeviceInfo( - name="IBS-TH EEFF", - model="IBS-TH", + name=f"{name} EEFF", + model=name, manufacturer="INKBIRD", sw_version=None, hw_version=None, @@ -132,8 +142,8 @@ async def test_polling_sensor(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 0 with patch( - "homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll", - return_value=_make_sensor_update(10.24), + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update("IBS-TH", 10.24), ): inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) await hass.async_block_till_done() @@ -149,8 +159,8 @@ async def test_polling_sensor(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" with patch( - "homeassistant.components.inkbird.INKBIRDBluetoothDeviceData.async_poll", - return_value=_make_sensor_update(20.24), + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData.async_poll", + return_value=_make_sensor_update("IBS-TH", 20.24), ): async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_POLL_INTERVAL) inject_bluetooth_service_info(hass, SPS_PASSIVE_SERVICE_INFO) @@ -162,3 +172,87 @@ async def test_polling_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_notify_sensor_no_advertisement(hass: HomeAssistant) -> None: + """Test setting up a notify sensor that has no advertisement.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="62:00:A1:3C:AE:7B", + data={CONF_DEVICE_TYPE: "IAM-T1"}, + ) + entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_notify_sensor(hass: HomeAssistant) -> None: + """Test setting up a notify sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="62:00:A1:3C:AE:7B", + data={CONF_DEVICE_TYPE: "IAM-T1"}, + ) + entry.add_to_hass(hass) + inject_bluetooth_service_info(hass, IAM_T1_SERVICE_INFO) + saved_update_callback = None + saved_device_data_changed_callback = None + + class MockINKBIRDBluetoothDeviceData(INKBIRDBluetoothDeviceData): + def __init__( + self, + device_type: Model | str | None = None, + device_data: dict[str, Any] | None = None, + update_callback: Callable[[SensorUpdate], None] | None = None, + device_data_changed_callback: Callable[[dict[str, Any]], None] + | None = None, + ) -> None: + nonlocal saved_update_callback + nonlocal saved_device_data_changed_callback + saved_update_callback = update_callback + saved_device_data_changed_callback = device_data_changed_callback + super().__init__( + device_type=device_type, + device_data=device_data, + update_callback=update_callback, + device_data_changed_callback=device_data_changed_callback, + ) + + mock_client = MagicMock(start_notify=AsyncMock(), disconnect=AsyncMock()) + with ( + patch( + "homeassistant.components.inkbird.coordinator.INKBIRDBluetoothDeviceData", + MockINKBIRDBluetoothDeviceData, + ), + patch("inkbird_ble.parser.establish_connection", return_value=mock_client), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert len(hass.states.async_all()) == 0 + + saved_update_callback(_make_sensor_update("IAM-T1", 10.24)) + + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.iam_t1_eeff_humidity") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "10.24" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IAM-T1 EEFF Humidity" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IAM-T1" + + saved_device_data_changed_callback({"temp_unit": "F"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "F"} + + saved_device_data_changed_callback({"temp_unit": "C"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"} + + saved_device_data_changed_callback({"temp_unit": "C"}) + assert entry.data[CONF_DEVICE_DATA] == {"temp_unit": "C"} From 621326f4e4745a6175b9a8039c23bfe60c2d68df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 23:27:46 -1000 Subject: [PATCH 075/116] Small cleanups to the inkbird coordinator (#142911) --- homeassistant/components/inkbird/coordinator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py index ed55bc79115..d52ebd83595 100644 --- a/homeassistant/components/inkbird/coordinator.py +++ b/homeassistant/components/inkbird/coordinator.py @@ -50,11 +50,6 @@ class INKBIRDActiveBluetoothProcessorCoordinator( self._device_data = device_data address = entry.unique_id assert address is not None - entry.async_on_unload( - async_track_time_interval( - hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL - ) - ) super().__init__( hass=hass, logger=_LOGGER, @@ -74,6 +69,11 @@ class INKBIRDActiveBluetoothProcessorCoordinator( self._async_device_data_changed, ) if not self._data.uses_notify: + self._entry.async_on_unload( + async_track_time_interval( + self.hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL + ) + ) return if not (service_info := async_last_service_info(self.hass, self.address)): raise ConfigEntryNotReady( From 35187a4b529de9da9e0d21e52ee3519780924d2a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 11:39:19 +0200 Subject: [PATCH 076/116] =?UTF-8?q?Fix=20typo=20"Could=20not=20login=20?= =?UTF-8?q?=E2=80=A6"=20and=20add=20common=20state=20in=20`xiaomi=5Fmiio`?= =?UTF-8?q?=20(#142648)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/xiaomi_miio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 7df4dc18283..e66cd04d9ae 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -14,7 +14,7 @@ "unknown_device": "The device model is not known, not able to set up the device using config flow.", "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials." + "cloud_login_error": "Could not log in to Xiaomi Miio Cloud, check the credentials." }, "flow_title": "{name}", "step": { @@ -100,7 +100,7 @@ "preset_mode": { "state": { "nature": "Nature", - "normal": "Normal" + "normal": "[%key:common::state::normal%]" } } } From 53b991fb54fb9aa4f2681d9adb2ac69c3a08e0c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 23:40:54 -1000 Subject: [PATCH 077/116] Add preset modes to HKC fans (#142528) --- .../components/homekit_controller/fan.py | 77 +++- .../snapshots/test_init.ambr | 86 ++-- .../components/homekit_controller/test_fan.py | 380 ++++++++++++++++++ 3 files changed, 504 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 9ba476a0ef3..4138277d81c 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -5,6 +5,10 @@ from __future__ import annotations from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import ( + TargetAirPurifierStateValues, + TargetFanStateValues, +) from aiohomekit.model.services import Service, ServicesTypes from propcache.api import cached_property @@ -35,6 +39,8 @@ DIRECTION_TO_HK = { } HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()} +PRESET_AUTO = "auto" + class BaseHomeKitFan(HomeKitEntity, FanEntity): """Representation of a Homekit fan.""" @@ -42,6 +48,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): # This must be set in subclasses to the name of a boolean characteristic # that controls whether the fan is on or off. on_characteristic: str + preset_char = CharacteristicsTypes.FAN_STATE_TARGET + preset_manual_value: int = TargetFanStateValues.MANUAL + preset_automatic_value: int = TargetFanStateValues.AUTOMATIC @callback def _async_reconfigure(self) -> None: @@ -51,6 +60,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): "_speed_range", "_min_speed", "_max_speed", + "preset_modes", "speed_count", "supported_features", ) @@ -59,12 +69,15 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" - return [ + types = [ CharacteristicsTypes.SWING_MODE, CharacteristicsTypes.ROTATION_DIRECTION, CharacteristicsTypes.ROTATION_SPEED, self.on_characteristic, ] + if self.service.has(self.preset_char): + types.append(self.preset_char) + return types @property def is_on(self) -> bool: @@ -124,6 +137,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if self.service.has(CharacteristicsTypes.SWING_MODE): features |= FanEntityFeature.OSCILLATE + if self.service.has(self.preset_char): + features |= FanEntityFeature.PRESET_MODE + return features @cached_property @@ -134,6 +150,32 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): / max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0) ) + @cached_property + def preset_modes(self) -> list[str]: + """Return the preset modes.""" + return [PRESET_AUTO] if self.service.has(self.preset_char) else [] + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if ( + self.service.has(self.preset_char) + and self.service.value(self.preset_char) == self.preset_automatic_value + ): + return PRESET_AUTO + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + if self.service.has(self.preset_char): + await self.async_put_characteristics( + { + self.preset_char: self.preset_automatic_value + if preset_mode == PRESET_AUTO + else self.preset_manual_value + } + ) + async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" await self.async_put_characteristics( @@ -146,13 +188,16 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): await self.async_turn_off() return - await self.async_put_characteristics( - { - CharacteristicsTypes.ROTATION_SPEED: round( - percentage_to_ranged_value(self._speed_range, percentage) - ) - } - ) + characteristics = { + CharacteristicsTypes.ROTATION_SPEED: round( + percentage_to_ranged_value(self._speed_range, percentage) + ) + } + + if FanEntityFeature.PRESET_MODE in self.supported_features: + characteristics[self.preset_char] = self.preset_manual_value + + await self.async_put_characteristics(characteristics) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" @@ -172,13 +217,17 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if not self.is_on: characteristics[self.on_characteristic] = True - if ( + if preset_mode == PRESET_AUTO: + characteristics[self.preset_char] = self.preset_automatic_value + elif ( percentage is not None and FanEntityFeature.SET_SPEED in self.supported_features ): characteristics[CharacteristicsTypes.ROTATION_SPEED] = round( percentage_to_ranged_value(self._speed_range, percentage) ) + if FanEntityFeature.PRESET_MODE in self.supported_features: + characteristics[self.preset_char] = self.preset_manual_value if characteristics: await self.async_put_characteristics(characteristics) @@ -200,10 +249,18 @@ class HomeKitFanV2(BaseHomeKitFan): on_characteristic = CharacteristicsTypes.ACTIVE +class HomeKitAirPurifer(HomeKitFanV2): + """Implement air purifier support for public.hap.service.airpurifier.""" + + preset_char = CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET + preset_manual_value = TargetAirPurifierStateValues.MANUAL + preset_automatic_value = TargetAirPurifierStateValues.AUTOMATIC + + ENTITY_TYPES = { ServicesTypes.FAN: HomeKitFanV1, ServicesTypes.FAN_V2: HomeKitFanV2, - ServicesTypes.AIR_PURIFIER: HomeKitFanV2, + ServicesTypes.AIR_PURIFIER: HomeKitAirPurifer, } diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 3bb9eb48106..324040f850f 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -86,7 +86,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -110,7 +112,7 @@ 'original_name': 'Airversa AP2 1808 AirPurifier', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832', 'unit_of_measurement': None, @@ -120,9 +122,11 @@ 'friendly_name': 'Airversa AP2 1808 AirPurifier', 'percentage': 0, 'percentage_step': 20.0, - 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_mode': 'auto', + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.airversa_ap2_1808_airpurifier', 'state': 'off', @@ -10562,7 +10566,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -10597,7 +10602,8 @@ 'percentage': 66, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.haa_c718b3', @@ -11248,7 +11254,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -11283,7 +11290,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.ceiling_fan', @@ -11458,7 +11466,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -11494,7 +11503,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -11655,7 +11665,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -11679,7 +11691,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, @@ -11691,8 +11703,10 @@ 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.89_living_room', 'state': 'on', @@ -12703,7 +12717,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -12738,7 +12753,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.ceiling_fan', @@ -12913,7 +12929,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -12950,7 +12967,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -13129,7 +13147,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -13166,7 +13185,8 @@ 'percentage': 0, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.living_room_fan', @@ -13336,7 +13356,9 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + 'auto', + ]), }), 'categories': dict({ }), @@ -13360,7 +13382,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_175', 'unit_of_measurement': None, @@ -13372,8 +13394,10 @@ 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': None, - 'supported_features': , + 'preset_modes': list([ + 'auto', + ]), + 'supported_features': , }), 'entity_id': 'fan.89_living_room', 'state': 'on', @@ -17967,7 +17991,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -18002,7 +18027,8 @@ 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', @@ -21777,7 +21803,8 @@ ]), 'area_id': None, 'capabilities': dict({ - 'preset_modes': None, + 'preset_modes': list([ + ]), }), 'categories': dict({ }), @@ -21813,7 +21840,8 @@ 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, - 'preset_modes': None, + 'preset_modes': list([ + ]), 'supported_features': , }), 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 2c498e1a9c1..e012c1be339 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -47,6 +47,26 @@ def create_fanv2_service(accessory: Accessory) -> None: swing_mode.value = 0 +def create_fanv2_service_with_target_state(accessory: Accessory) -> None: + """Define fan v2 characteristics with target as per HAP spec.""" + service = accessory.add_service(ServicesTypes.FAN_V2) + + target_state = service.add_char(CharacteristicsTypes.FAN_STATE_TARGET) + target_state.value = 0 + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + def create_fanv2_service_non_standard_rotation_range(accessory: Accessory) -> None: """Define fan v2 with a non-standard rotation range.""" service = accessory.add_service(ServicesTypes.FAN_V2) @@ -93,6 +113,27 @@ def create_fanv2_service_without_rotation_speed(accessory: Accessory) -> None: swing_mode.value = 0 +def create_air_purifier_service(accessory: Accessory) -> None: + """Define air purifier characteristics as per HAP spec.""" + service = accessory.add_service(ServicesTypes.AIR_PURIFIER) + + target_state = service.add_char(CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET) + target_state.value = 0 + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + speed.minStep = 25 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + async def test_fan_read_state( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -606,6 +647,70 @@ async def test_v2_set_percentage( ) +async def test_fanv2_set_preset_mode( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set preset mode when target state is available.""" + helper = await setup_test_component( + hass, get_next_aid(), create_fanv2_service_with_target_state + ) + + await helper.async_update(ServicesTypes.FAN_V2, {CharacteristicsTypes.ACTIVE: 1}) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.ROTATION_SPEED: 100.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_preset_mode", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.FAN_STATE_TARGET: 1, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.ROTATION_SPEED: 33.0, + CharacteristicsTypes.FAN_STATE_TARGET: 0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.FAN_V2, + { + CharacteristicsTypes.FAN_STATE_TARGET: 1, + }, + ) + + async def test_v2_set_percentage_with_min_step( hass: HomeAssistant, get_next_aid: Callable[[], int] ) -> None: @@ -847,6 +952,281 @@ async def test_v2_set_percentage_non_standard_rotation_range( ) +async def test_air_purifier_turn_on( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn on an air purifier.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 75.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 1, + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + +async def test_air_purifier_turn_off( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we can turn an air purifier fan off.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_speed( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set air purifier fan speed.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 75.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 25.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_percentage( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set air purifier fan speed by percentage.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 75}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 75, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) + + +async def test_air_purifier_set_preset_mode( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that we set preset mode when target state is available.""" + helper = await setup_test_component( + hass, get_next_aid(), create_air_purifier_service + ) + + await helper.async_update( + ServicesTypes.AIR_PURIFIER, {CharacteristicsTypes.ACTIVE: 1} + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 100}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 100.0, + }, + ) + + await hass.services.async_call( + "fan", + "set_preset_mode", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 1, + }, + ) + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 33}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.ROTATION_SPEED: 25.0, + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 0, + }, + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice", "preset_mode": "auto"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.AIR_PURIFIER, + { + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: 1, + }, + ) + + async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 908a7c69915755054d4d3063e8bc7e1b0eeca0ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Apr 2025 00:07:47 -1000 Subject: [PATCH 078/116] Fix flakey bluetooth options flow tests (#142920) --- tests/components/bluetooth/test_config_flow.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 45d177de132..4561bcfb802 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -56,6 +56,7 @@ async def test_options_flow_disabled_not_setup( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures("macos_adapter") @@ -396,6 +397,7 @@ async def test_options_flow_linux(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_PASSIVE] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -425,6 +427,7 @@ async def test_options_flow_disabled_macos( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is False await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -457,6 +460,7 @@ async def test_options_flow_enabled_linux( response = await ws_client.receive_json() assert response["result"][0]["supports_options"] is True await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -487,6 +491,8 @@ async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "remote_adapters_not_supported" + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures( @@ -514,6 +520,8 @@ async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> Non result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "local_adapters_no_passive_support" + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() @pytest.mark.usefixtures("one_adapter") From 1480b77461b518abafe566f5f863c37392389dc0 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Mon, 14 Apr 2025 13:15:46 +0300 Subject: [PATCH 079/116] Don't do I/O while getting Jewish calendar data schema (#142919) --- .../components/jewish_calendar/config_flow.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index 23bcb23435b..3cec9e9e24e 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -61,11 +61,14 @@ OPTIONS_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) -def _get_data_schema(hass: HomeAssistant) -> vol.Schema: +async def _get_data_schema(hass: HomeAssistant) -> vol.Schema: default_location = { CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude, } + get_timezones: list[str] = list( + await hass.async_add_executor_job(zoneinfo.available_timezones) + ) return vol.Schema( { vol.Required(CONF_DIASPORA, default=DEFAULT_DIASPORA): BooleanSelector(), @@ -75,9 +78,7 @@ def _get_data_schema(hass: HomeAssistant) -> vol.Schema: vol.Optional(CONF_LOCATION, default=default_location): LocationSelector(), vol.Optional(CONF_ELEVATION, default=hass.config.elevation): int, vol.Optional(CONF_TIME_ZONE, default=hass.config.time_zone): SelectSelector( - SelectSelectorConfig( - options=sorted(zoneinfo.available_timezones()), - ) + SelectSelectorConfig(options=get_timezones, sort=True) ), } ) @@ -109,7 +110,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - _get_data_schema(self.hass), user_input + await _get_data_schema(self.hass), user_input ), ) @@ -121,7 +122,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): if not user_input: return self.async_show_form( data_schema=self.add_suggested_values_to_schema( - _get_data_schema(self.hass), + await _get_data_schema(self.hass), reconfigure_entry.data, ), step_id="reconfigure", From 583eb1a80e352692c3b6897bc74bfa1b79cc35d2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Apr 2025 12:24:32 +0200 Subject: [PATCH 080/116] Remove state attributes in Totalconnect (#142625) --- .../totalconnect/alarm_control_panel.py | 31 +++---------- .../snapshots/test_alarm_control_panel.ambr | 16 ------- .../totalconnect/test_alarm_control_panel.py | 44 +------------------ 3 files changed, 6 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 9ed29ea01c8..e31e6085832 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -97,22 +97,6 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): @property def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the device.""" - # State attributes can be removed in 2025.3 - attr = { - "location_id": self._location.location_id, - "partition": self._partition_id, - "ac_loss": self._location.ac_loss, - "low_battery": self._location.low_battery, - "cover_tampered": self._location.is_cover_tampered(), - "triggered_source": None, - "triggered_zone": None, - } - - if self._partition_id == 1: - attr["location_name"] = self.device.name - else: - attr["location_name"] = f"{self.device.name} partition {self._partition_id}" - state: AlarmControlPanelState | None = None if self._partition.arming_state.is_disarmed(): state = AlarmControlPanelState.DISARMED @@ -128,17 +112,12 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): state = AlarmControlPanelState.ARMING elif self._partition.arming_state.is_disarming(): state = AlarmControlPanelState.DISARMING - elif self._partition.arming_state.is_triggered_police(): + elif ( + self._partition.arming_state.is_triggered_police() + or self._partition.arming_state.is_triggered_fire() + or self._partition.arming_state.is_triggered_gas() + ): state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Police/Medical" - elif self._partition.arming_state.is_triggered_fire(): - state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Fire/Smoke" - elif self._partition.arming_state.is_triggered_gas(): - state = AlarmControlPanelState.TRIGGERED - attr["triggered_source"] = "Carbon Monoxide" - - self._attr_extra_state_attributes = attr return state diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index a63319a6c76..ac32b50762f 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -36,19 +36,11 @@ # name: test_attributes[alarm_control_panel.test-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'ac_loss': False, 'changed_by': None, 'code_arm_required': False, 'code_format': None, - 'cover_tampered': False, 'friendly_name': 'test', - 'location_id': 123456, - 'location_name': 'test', - 'low_battery': False, - 'partition': 1, 'supported_features': , - 'triggered_source': None, - 'triggered_zone': None, }), 'context': , 'entity_id': 'alarm_control_panel.test', @@ -95,19 +87,11 @@ # name: test_attributes[alarm_control_panel.test_partition_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'ac_loss': False, 'changed_by': None, 'code_arm_required': False, 'code_format': None, - 'cover_tampered': False, 'friendly_name': 'test Partition 2', - 'location_id': 123456, - 'location_name': 'test partition 2', - 'low_battery': False, - 'partition': 2, 'supported_features': , - 'triggered_source': None, - 'triggered_zone': None, }), 'context': , 'entity_id': 'alarm_control_panel.test_partition_2', diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index bc76f7243ca..6ba067b8ae2 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -50,9 +50,6 @@ from .common import ( RESPONSE_DISARMED, RESPONSE_DISARMING, RESPONSE_SUCCESS, - RESPONSE_TRIGGERED_CARBON_MONOXIDE, - RESPONSE_TRIGGERED_FIRE, - RESPONSE_TRIGGERED_POLICE, RESPONSE_UNKNOWN, RESPONSE_USER_CODE_INVALID, TOTALCONNECT_REQUEST, @@ -195,7 +192,7 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm home instant" + assert str(err.value) == "Usercode is invalid, did not arm home instant" assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -513,45 +510,6 @@ async def test_disarming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMING -async def test_triggered_fire(hass: HomeAssistant) -> None: - """Test triggered by fire.""" - responses = [RESPONSE_TRIGGERED_FIRE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Fire/Smoke" - assert mock_request.call_count == 1 - - -async def test_triggered_police(hass: HomeAssistant) -> None: - """Test triggered by police.""" - responses = [RESPONSE_TRIGGERED_POLICE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Police/Medical" - assert mock_request.call_count == 1 - - -async def test_triggered_carbon_monoxide(hass: HomeAssistant) -> None: - """Test triggered by carbon monoxide.""" - responses = [RESPONSE_TRIGGERED_CARBON_MONOXIDE] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) - assert state.state == AlarmControlPanelState.TRIGGERED - assert state.attributes.get("triggered_source") == "Carbon Monoxide" - assert mock_request.call_count == 1 - - async def test_armed_custom(hass: HomeAssistant) -> None: """Test armed custom.""" responses = [RESPONSE_ARMED_CUSTOM] From 458162c3f5f5285847cd171f2f7cd5e0185ee744 Mon Sep 17 00:00:00 2001 From: Mathijs van de Nes Date: Mon, 14 Apr 2025 12:31:44 +0200 Subject: [PATCH 081/116] Fix typo in util.ssl test (#142799) --- tests/util/test_ssl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index 0c30ad9b9b3..0cef48e0d84 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -59,7 +59,7 @@ def test_ssl_context_caching() -> None: ) -def test_cteate_client_context(mock_sslcontext) -> None: +def test_create_client_context(mock_sslcontext) -> None: """Test create client context.""" with patch("homeassistant.util.ssl.ssl.SSLContext", return_value=mock_sslcontext): client_context() From 589633bc239e88faf018bc8f0b0ef772d9d48af7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 12:38:26 +0200 Subject: [PATCH 082/116] Fix spelling of "off-peak" in `huisbaasje` (#142810) --- homeassistant/components/huisbaasje/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/huisbaasje/strings.json b/homeassistant/components/huisbaasje/strings.json index de112f7519f..3958e6a8903 100644 --- a/homeassistant/components/huisbaasje/strings.json +++ b/homeassistant/components/huisbaasje/strings.json @@ -26,25 +26,25 @@ "name": "Current power in peak" }, "current_power_off_peak": { - "name": "Current power in off peak" + "name": "Current power in off-peak" }, "current_power_out_peak": { "name": "Current power out peak" }, "current_power_out_off_peak": { - "name": "Current power out off peak" + "name": "Current power out off-peak" }, "energy_consumption_peak_today": { "name": "Energy consumption peak today" }, "energy_consumption_off_peak_today": { - "name": "Energy consumption off peak today" + "name": "Energy consumption off-peak today" }, "energy_production_peak_today": { "name": "Energy production peak today" }, "energy_production_off_peak_today": { - "name": "Energy production off peak today" + "name": "Energy production off-peak today" }, "energy_today": { "name": "Energy today" From 1892c8fa6239ae4e8816787c944d1eff9e2ad53e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Apr 2025 00:40:55 -1000 Subject: [PATCH 083/116] Bump habluetooth to 3.38.1 (#142915) --- 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 d13411b62c4..e824720adab 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.27.0", "dbus-fast==2.43.0", - "habluetooth==3.37.0" + "habluetooth==3.38.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 24fb7709782..cf46982af78 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.43.0 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.37.0 +habluetooth==3.38.1 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index c3c53614db4..bef835a76dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1114,7 +1114,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.37.0 +habluetooth==3.38.1 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df194a11038..a2b1b6cda7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -956,7 +956,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.37.0 +habluetooth==3.38.1 # homeassistant.components.cloud hass-nabucasa==0.94.0 From c8972a22343bc23b8319dd539ec248c8085a6db1 Mon Sep 17 00:00:00 2001 From: Stefano Angeleri Date: Mon, 14 Apr 2025 12:59:28 +0200 Subject: [PATCH 084/116] Fix powerwall display of actual remaining battery, instead of reserved capacity (#142391) --- homeassistant/components/powerwall/sensor.py | 1 - tests/components/powerwall/test_sensor.py | 1 - 2 files changed, 2 deletions(-) diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index b4988133727..f242d2c67e6 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -297,7 +297,6 @@ class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): _attr_translation_key = "backup_reserve" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE - _attr_device_class = SensorDeviceClass.BATTERY @property def unique_id(self) -> str: diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 9b533304fbc..7f23550f522 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -118,7 +118,6 @@ async def test_sensors(hass: HomeAssistant, device_registry: dr.DeviceRegistry) expected_attributes = { "unit_of_measurement": PERCENTAGE, "friendly_name": "MySite Backup reserve", - "device_class": "battery", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears From b5f15b6d67c29e9f9c5d9dbb8d2a02e517f8b2b5 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 14 Apr 2025 13:51:55 +0200 Subject: [PATCH 085/116] Bump aioautomower to 2025.4.0 (#142609) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 7f728148be3..d26cc18c127 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.3.2"] + "requirements": ["aioautomower==2025.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bef835a76dc..dba70d2e8c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.3.2 +aioautomower==2025.4.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2b1b6cda7d..116b2b5dc2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.3.2 +aioautomower==2025.4.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From f84f6aa713545cb8894d88b07ff2614c4bd570f1 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 14 Apr 2025 05:58:54 -0600 Subject: [PATCH 086/116] Fix vesync purifier 131 tests (#142860) --- tests/components/vesync/common.py | 6 ++++- .../fixtures/air-purifier-131s-detail.json | 25 +++++++++++++++++++ .../vesync/fixtures/purifier-detail.json | 10 -------- .../components/vesync/snapshots/test_fan.ambr | 8 +++++- .../vesync/snapshots/test_sensor.ambr | 4 +-- 5 files changed, 39 insertions(+), 14 deletions(-) create mode 100644 tests/components/vesync/fixtures/air-purifier-131s-detail.json delete mode 100644 tests/components/vesync/fixtures/purifier-detail.json diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 39a92778727..18cb094563e 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -27,7 +27,11 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") ], "Air Purifier 131s": [ - ("post", "/131airPurifier/v1/device/deviceDetail", "purifier-detail.json") + ( + "post", + "/131airPurifier/v1/device/deviceDetail", + "air-purifier-131s-detail.json", + ) ], "Air Purifier 200s": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") diff --git a/tests/components/vesync/fixtures/air-purifier-131s-detail.json b/tests/components/vesync/fixtures/air-purifier-131s-detail.json new file mode 100644 index 00000000000..a7598c621d3 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-131s-detail.json @@ -0,0 +1,25 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1744558015", + "screenStatus": "on", + "filterLife": { + "change": false, + "useHour": 3034, + "percent": 25 + }, + "activeTime": 0, + "timer": null, + "scheduleCount": 0, + "schedule": null, + "levelNew": 0, + "airQuality": "excellent", + "level": null, + "mode": "sleep", + "deviceName": "Levoit 131S Air Purifier", + "currentFirmVersion": "2.0.58", + "childLock": "off", + "deviceStatus": "on", + "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/airpurifier131_240.png", + "connectionStatus": "online" +} diff --git a/tests/components/vesync/fixtures/purifier-detail.json b/tests/components/vesync/fixtures/purifier-detail.json deleted file mode 100644 index de0843975c3..00000000000 --- a/tests/components/vesync/fixtures/purifier-detail.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "code": 0, - "deviceStatus": "on", - "activeTime": 50, - "filterLife": 90, - "screenStatus": "on", - "mode": "auto", - "level": 2, - "airQuality": 95 -} diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 0b56a08eeff..92473647a39 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -78,11 +78,17 @@ # name: test_fan_state[Air Purifier 131s][fan.air_purifier_131s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': 0, 'friendly_name': 'Air Purifier 131s', + 'mode': 'sleep', + 'percentage': None, + 'percentage_step': 33.333333333333336, + 'preset_mode': 'sleep', 'preset_modes': list([ 'auto', 'sleep', ]), + 'screen_status': 'on', 'supported_features': , }), 'context': , @@ -90,7 +96,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'on', }) # --- # name: test_fan_state[Air Purifier 200s][devices] diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index c701fa8a324..ecae8fa7674 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -114,7 +114,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'excellent', }) # --- # name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_filter_lifetime] @@ -129,7 +129,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '25', }) # --- # name: test_sensor_state[Air Purifier 200s][devices] From 514363f1c5e35a835d4dc372da0d5289fbefdc26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Apr 2025 02:24:43 -1000 Subject: [PATCH 087/116] Use configured names in HomeKit for child accessories (#142531) --- homeassistant/components/homekit/type_fans.py | 8 ++- .../components/homekit/type_media_players.py | 55 +++++++++++++---- .../components/homekit/type_switches.py | 11 ++-- .../components/homekit/type_triggers.py | 6 +- tests/components/homekit/test_diagnostics.py | 60 +++++++++++++------ tests/components/homekit/test_type_fans.py | 13 +++- .../homekit/test_type_media_players.py | 6 ++ .../components/homekit/test_type_switches.py | 6 ++ .../components/homekit/test_type_triggers.py | 10 +++- 9 files changed, 135 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 595dbc7ded3..5c91dd0c3bb 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -35,6 +35,7 @@ from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_NAME, CHAR_ON, CHAR_ROTATION_DIRECTION, @@ -120,7 +121,9 @@ class Fan(HomeAccessory): continue preset_serv = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=preset_mode + SERV_SWITCH, + [CHAR_NAME, CHAR_CONFIGURED_NAME], + unique_id=preset_mode, ) serv_fan.add_linked_service(preset_serv) preset_serv.configure_char( @@ -129,6 +132,9 @@ class Fan(HomeAccessory): f"{self.display_name} {preset_mode}" ), ) + preset_serv.configure_char( + CHAR_CONFIGURED_NAME, value=cleanup_name_for_homekit(preset_mode) + ) def setter_callback(value: int, preset_mode: str = preset_mode) -> None: self.set_preset_mode(value, preset_mode) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index adb16da5a2d..88d227d0ca5 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -41,6 +41,7 @@ from .const import ( ATTR_KEY_NAME, CATEGORY_RECEIVER, CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_MUTE, CHAR_NAME, CHAR_ON, @@ -100,41 +101,67 @@ class MediaPlayer(HomeAccessory): ) if FEATURE_ON_OFF in feature_list: - name = self.generate_service_name(FEATURE_ON_OFF) serv_on_off = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_ON_OFF + SERV_SWITCH, [CHAR_CONFIGURED_NAME, CHAR_NAME], unique_id=FEATURE_ON_OFF + ) + serv_on_off.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_ON_OFF) + ) + serv_on_off.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_ON_OFF), ) - serv_on_off.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( CHAR_ON, value=False, setter_callback=self.set_on_off ) if FEATURE_PLAY_PAUSE in feature_list: - name = self.generate_service_name(FEATURE_PLAY_PAUSE) serv_play_pause = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_PAUSE + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_PLAY_PAUSE, + ) + serv_play_pause.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_PAUSE) + ) + serv_play_pause.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_PLAY_PAUSE), ) - serv_play_pause.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_pause ) if FEATURE_PLAY_STOP in feature_list: - name = self.generate_service_name(FEATURE_PLAY_STOP) serv_play_stop = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_STOP + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_PLAY_STOP, + ) + serv_play_stop.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_STOP) + ) + serv_play_stop.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_PLAY_STOP), ) - serv_play_stop.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( CHAR_ON, value=False, setter_callback=self.set_play_stop ) if FEATURE_TOGGLE_MUTE in feature_list: - name = self.generate_service_name(FEATURE_TOGGLE_MUTE) serv_toggle_mute = self.add_preload_service( - SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_TOGGLE_MUTE + SERV_SWITCH, + [CHAR_CONFIGURED_NAME, CHAR_NAME], + unique_id=FEATURE_TOGGLE_MUTE, + ) + serv_toggle_mute.configure_char( + CHAR_NAME, value=self.generate_service_name(FEATURE_TOGGLE_MUTE) + ) + serv_toggle_mute.configure_char( + CHAR_CONFIGURED_NAME, + value=self.generated_configured_name(FEATURE_TOGGLE_MUTE), ) - serv_toggle_mute.configure_char(CHAR_NAME, value=name) self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( CHAR_ON, value=False, setter_callback=self.set_toggle_mute ) @@ -146,6 +173,10 @@ class MediaPlayer(HomeAccessory): f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" ) + def generated_configured_name(self, mode: str) -> str: + """Generate name for individual service.""" + return cleanup_name_for_homekit(MODE_FRIENDLY_NAME[mode]) + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 8c6fc1ed672..18150c820c3 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -49,6 +49,7 @@ from homeassistant.helpers.event import async_call_later from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( CHAR_ACTIVE, + CHAR_CONFIGURED_NAME, CHAR_IN_USE, CHAR_NAME, CHAR_ON, @@ -360,11 +361,13 @@ class SelectSwitch(HomeAccessory): options = state.attributes[ATTR_OPTIONS] for option in options: serv_option = self.add_preload_service( - SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE], unique_id=option - ) - serv_option.configure_char( - CHAR_NAME, value=cleanup_name_for_homekit(option) + SERV_OUTLET, + [CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_IN_USE], + unique_id=option, ) + name = cleanup_name_for_homekit(option) + serv_option.configure_char(CHAR_NAME, value=name) + serv_option.configure_char(CHAR_CONFIGURED_NAME, value=name) serv_option.configure_char(CHAR_IN_USE, value=False) self.select_chars[option] = serv_option.configure_char( CHAR_ON, diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index f32c4f55a0f..44db65d7b0b 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -15,6 +15,7 @@ from homeassistant.helpers.trigger import async_initialize_triggers from .accessories import TYPES, HomeAccessory from .aidmanager import get_system_unique_id from .const import ( + CHAR_CONFIGURED_NAME, CHAR_NAME, CHAR_PROGRAMMABLE_SWITCH_EVENT, CHAR_SERVICE_LABEL_INDEX, @@ -66,7 +67,7 @@ class DeviceTriggerAccessory(HomeAccessory): trigger_name = cleanup_name_for_homekit(" ".join(trigger_name_parts)) serv_stateless_switch = self.add_preload_service( SERV_STATELESS_PROGRAMMABLE_SWITCH, - [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX], + [CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_SERVICE_LABEL_INDEX], unique_id=unique_id, ) self.triggers.append( @@ -77,6 +78,9 @@ class DeviceTriggerAccessory(HomeAccessory): ) ) serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name) + serv_stateless_switch.configure_char( + CHAR_CONFIGURED_NAME, value=trigger_name + ) serv_stateless_switch.configure_char( CHAR_SERVICE_LABEL_INDEX, value=idx + 1 ) diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index ce3c954c447..912c5953176 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -453,7 +453,7 @@ async def test_config_entry_with_trigger_accessory( "iid": 6, "perms": ["pr"], "type": "30", - "value": ANY, + "value": device_id, }, { "format": "string", @@ -484,8 +484,15 @@ async def test_config_entry_with_trigger_accessory( "value": "Ceiling Lights Changed States", }, { - "format": "uint8", + "format": "string", "iid": 11, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Changed States", + }, + { + "format": "uint8", + "iid": 12, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -495,28 +502,28 @@ async def test_config_entry_with_trigger_accessory( }, ], "iid": 8, - "linked": [12], + "linked": [13], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 13, + "iid": 14, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 12, + "iid": 13, "type": "CC", }, { "characteristics": [ { "format": "uint8", - "iid": 15, + "iid": 16, "perms": ["pr", "ev"], "type": "73", "valid-values": [0], @@ -524,14 +531,21 @@ async def test_config_entry_with_trigger_accessory( }, { "format": "string", - "iid": 16, + "iid": 17, "perms": ["pr"], "type": "23", "value": "Ceiling Lights Turned Off", }, + { + "format": "string", + "iid": 18, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Turned Off", + }, { "format": "uint8", - "iid": 17, + "iid": 19, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -540,29 +554,29 @@ async def test_config_entry_with_trigger_accessory( "value": 2, }, ], - "iid": 14, - "linked": [18], + "iid": 15, + "linked": [20], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 19, + "iid": 21, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 18, + "iid": 20, "type": "CC", }, { "characteristics": [ { "format": "uint8", - "iid": 21, + "iid": 23, "perms": ["pr", "ev"], "type": "73", "valid-values": [0], @@ -570,14 +584,21 @@ async def test_config_entry_with_trigger_accessory( }, { "format": "string", - "iid": 22, + "iid": 24, "perms": ["pr"], "type": "23", "value": "Ceiling Lights Turned On", }, + { + "format": "string", + "iid": 25, + "perms": ["pr", "pw", "ev"], + "type": "E3", + "value": "Ceiling Lights Turned On", + }, { "format": "uint8", - "iid": 23, + "iid": 26, "maxValue": 255, "minStep": 1, "minValue": 1, @@ -586,22 +607,22 @@ async def test_config_entry_with_trigger_accessory( "value": 3, }, ], - "iid": 20, - "linked": [24], + "iid": 22, + "linked": [27], "type": "89", }, { "characteristics": [ { "format": "uint8", - "iid": 25, + "iid": 28, "perms": ["pr"], "type": "CD", "valid-values": [0, 1], "value": 1, } ], - "iid": 24, + "iid": 27, "type": "CC", }, ], @@ -626,6 +647,7 @@ async def test_config_entry_with_trigger_accessory( "pairing_id": ANY, "status": 1, } + with ( patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch("homeassistant.components.homekit.HomeKit.async_stop"), diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 67392f11f14..e6f81c1729f 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -14,7 +14,13 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, FanEntityFeature, ) -from homeassistant.components.homekit.const import ATTR_VALUE, PROP_MIN_STEP +from homeassistant.components.homekit.accessories import HomeDriver +from homeassistant.components.homekit.const import ( + ATTR_VALUE, + CHAR_CONFIGURED_NAME, + PROP_MIN_STEP, + SERV_SWITCH, +) from homeassistant.components.homekit.type_fans import Fan from homeassistant.const import ( ATTR_ENTITY_ID, @@ -603,7 +609,7 @@ async def test_fan_restore( async def test_fan_multiple_preset_modes( - hass: HomeAssistant, hk_driver, events: list[Event] + hass: HomeAssistant, hk_driver: HomeDriver, events: list[Event] ) -> None: """Test fan with multiple preset modes.""" entity_id = "fan.demo" @@ -623,6 +629,9 @@ async def test_fan_multiple_preset_modes( assert acc.preset_mode_chars["auto"].value == 1 assert acc.preset_mode_chars["smart"].value == 0 + switch_service = acc.get_service(SERV_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "auto" acc.run() await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 78c35b15790..51d6e65bb1b 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -6,6 +6,7 @@ from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, + CHAR_CONFIGURED_NAME, CHAR_REMOTE_KEY, CONF_FEATURE_LIST, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, @@ -14,6 +15,7 @@ from homeassistant.components.homekit.const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, KEY_ARROW_RIGHT, + SERV_SWITCH, ) from homeassistant.components.homekit.type_media_players import ( MediaPlayer, @@ -74,6 +76,10 @@ async def test_media_player_set_state( assert acc.aid == 2 assert acc.category == 8 # Switch + switch_service = acc.get_service(SERV_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "Power" + assert acc.chars[FEATURE_ON_OFF].value is False assert acc.chars[FEATURE_PLAY_PAUSE].value is False assert acc.chars[FEATURE_PLAY_STOP].value is False diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 6a30877a795..3f0f0a3c22b 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -6,6 +6,8 @@ import pytest from homeassistant.components.homekit.const import ( ATTR_VALUE, + CHAR_CONFIGURED_NAME, + SERV_OUTLET, TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, @@ -568,6 +570,10 @@ async def test_input_select_switch( acc.run() await hass.async_block_till_done() + switch_service = acc.get_service(SERV_OUTLET) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "option1" + assert acc.select_chars["option1"].value is True assert acc.select_chars["option2"].value is False assert acc.select_chars["option3"].value is False diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index f7415ef5599..87948d589c0 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -3,7 +3,11 @@ from unittest.mock import MagicMock from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.homekit.const import CHAR_PROGRAMMABLE_SWITCH_EVENT +from homeassistant.components.homekit.const import ( + CHAR_CONFIGURED_NAME, + CHAR_PROGRAMMABLE_SWITCH_EVENT, + SERV_STATELESS_PROGRAMMABLE_SWITCH, +) from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -55,6 +59,10 @@ async def test_programmable_switch_button_fires_on_trigger( assert acc.device_id is device_id assert acc.available is True + switch_service = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) + configured_name_char = switch_service.get_characteristic(CHAR_CONFIGURED_NAME) + assert configured_name_char.value == "ceiling lights Changed States" + hk_driver.publish.reset_mock() hass.states.async_set("light.ceiling_lights", STATE_ON) await hass.async_block_till_done() From 5f2ae37ee56753a1c84ff7567e7edffeb62d27ab Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:26:29 +0200 Subject: [PATCH 088/116] Improve backup tests (#142785) --- tests/helpers/test_backup.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py index 10ff5cb855f..f6a4f28622e 100644 --- a/tests/helpers/test_backup.py +++ b/tests/helpers/test_backup.py @@ -17,6 +17,7 @@ async def test_async_get_manager(hass: HomeAssistant) -> None: backup_helper.async_initialize_backup(hass) task = asyncio.create_task(backup_helper.async_get_manager(hass)) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await hass.async_block_till_done() manager = await task assert manager is hass.data[backup_helper.DATA_MANAGER] @@ -36,7 +37,5 @@ async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> Non side_effect=Exception("Boom!"), ): assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) - with ( - pytest.raises(Exception, match="Boom!"), - ): + with pytest.raises(Exception, match="Boom!"): await backup_helper.async_get_manager(hass) From 8ec436423fdc7014a10c74c761206194fcb66787 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 14 Apr 2025 14:30:00 +0200 Subject: [PATCH 089/116] Add template function: device_name (#142683) --- homeassistant/helpers/template.py | 25 +++++++++++++ tests/helpers/test_template.py | 60 +++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9468eb6bf49..424cd3d978e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1413,6 +1413,28 @@ def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: ) +def device_name(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the device name from an device id, or entity id.""" + device_reg = device_registry.async_get(hass) + if device := device_reg.async_get(lookup_value): + return device.name_by_user or device.name + + ent_reg = entity_registry.async_get(hass) + # Import here, not at top-level to avoid circular import + from . import config_validation as cv # pylint: disable=import-outside-toplevel + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + if entity.device_id and (device := device_reg.async_get(entity.device_id)): + return device.name_by_user or device.name + + return None + + def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any: """Get the device specific attribute.""" device_reg = device_registry.async_get(hass) @@ -3230,6 +3252,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): # Device extensions + self.globals["device_name"] = hassfunction(device_name) + self.filters["device_name"] = self.globals["device_name"] + self.globals["device_attr"] = hassfunction(device_attr) self.filters["device_attr"] = self.globals["device_attr"] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 89d1c307fd7..43efe79e96f 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -3887,6 +3887,66 @@ async def test_device_id( assert info.rate_limit is None +async def test_device_name( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test device_name function.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Test non existing entity id + info = render_to_info(hass, "{{ device_name('sensor.fake') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing device id + info = render_to_info(hass, "{{ device_name('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ device_name(56) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test device with single entity + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="A light", + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") + assert_result_info(info, device_entry.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.name) + assert info.rate_limit is None + + # Test device after renaming + device_entry = device_registry.async_update_device( + device_entry.id, + name_by_user="My light", + ) + + info = render_to_info(hass, f"{{{{ device_name('{device_entry.id}') }}}}") + assert_result_info(info, device_entry.name_by_user) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_name('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.name_by_user) + assert info.rate_limit is None + + async def test_device_attr( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From f00dfd32d4acdf19563f3e03a4280a8daec968f0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Apr 2025 14:30:41 +0200 Subject: [PATCH 090/116] Remove config import in EmonCMS (#142624) --- .../components/emoncms/config_flow.py | 20 ---- homeassistant/components/emoncms/sensor.py | 102 +----------------- tests/components/emoncms/test_config_flow.py | 52 +-------- tests/components/emoncms/test_sensor.py | 57 +--------- 4 files changed, 7 insertions(+), 224 deletions(-) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index e0d4d0d03e9..8b3067b2cf4 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -17,7 +17,6 @@ from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import selector -from homeassistant.helpers.typing import ConfigType from .const import ( CONF_MESSAGE, @@ -27,7 +26,6 @@ from .const import ( FEED_ID, FEED_NAME, FEED_TAG, - LOGGER, ) @@ -153,24 +151,6 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult: - """Import config from yaml.""" - url = import_info[CONF_URL] - api_key = import_info[CONF_API_KEY] - include_only_feeds = None - if import_info.get(CONF_ONLY_INCLUDE_FEEDID) is not None: - include_only_feeds = list(map(str, import_info[CONF_ONLY_INCLUDE_FEEDID])) - config = { - CONF_API_KEY: api_key, - CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, - CONF_URL: url, - } - LOGGER.debug(config) - result = await self.async_step_user(config) - if errors := result.get("errors"): - return self.async_abort(reason=errors["base"]) - return result - class EmoncmsOptionsFlow(OptionsFlow): """Emoncms Options flow handler.""" diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 6321ccfafcd..c5a25104549 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -4,24 +4,16 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, - CONF_API_KEY, - CONF_ID, - CONF_UNIT_OF_MEASUREMENT, CONF_URL, - CONF_VALUE_TEMPLATE, PERCENTAGE, UnitOfApparentPower, UnitOfElectricCurrent, @@ -36,22 +28,15 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import ( - AddConfigEntryEntitiesCallback, - AddEntitiesCallback, -) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import template +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .config_flow import sensor_name from .const import ( CONF_EXCLUDE_FEEDID, CONF_ONLY_INCLUDE_FEEDID, - DOMAIN, FEED_ID, FEED_NAME, FEED_TAG, @@ -205,88 +190,7 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr" ATTR_SIZE = "Size" ATTR_TAG = "Tag" ATTR_USERID = "UserId" -CONF_SENSOR_NAMES = "sensor_names" DECIMALS = 2 -DEFAULT_UNIT = UnitOfPower.WATT - -ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none" - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_URL): cv.string, - vol.Required(CONF_ID): cv.positive_int, - vol.Exclusive(CONF_ONLY_INCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Exclusive(CONF_EXCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_SENSOR_NAMES): vol.All( - {cv.positive_int: vol.All(cv.string, vol.Length(min=1))} - ), - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import config from yaml.""" - if CONF_VALUE_TEMPLATE in config: - async_create_issue( - hass, - DOMAIN, - f"remove_{CONF_VALUE_TEMPLATE}_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.ERROR, - translation_key=f"remove_{CONF_VALUE_TEMPLATE}", - translation_placeholders={ - "domain": DOMAIN, - "parameter": CONF_VALUE_TEMPLATE, - }, - ) - return - if CONF_ONLY_INCLUDE_FEEDID not in config: - async_create_issue( - hass, - DOMAIN, - f"missing_{CONF_ONLY_INCLUDE_FEEDID}_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"missing_{CONF_ONLY_INCLUDE_FEEDID}", - translation_placeholders={ - "domain": DOMAIN, - }, - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if ( - result.get("type") == FlowResultType.CREATE_ENTRY - or result.get("reason") == "already_configured" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2025.3.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "emoncms", - }, - ) async def async_setup_entry( diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index 1914f23fb0b..fa8ae7ce068 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -3,64 +3,16 @@ from unittest.mock import AsyncMock from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_integration -from .conftest import EMONCMS_FAILURE, FLOW_RESULT_SINGLE_FEED, SENSOR_NAME, YAML +from .conftest import EMONCMS_FAILURE, SENSOR_NAME from tests.common import MockConfigEntry - -async def test_flow_import_include_feeds( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - emoncms_client: AsyncMock, -) -> None: - """YAML import with included feed - success test.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == SENSOR_NAME - assert result["data"] == FLOW_RESULT_SINGLE_FEED - - -async def test_flow_import_failure( - hass: HomeAssistant, - emoncms_client: AsyncMock, -) -> None: - """YAML import - failure test.""" - emoncms_client.async_request.return_value = EMONCMS_FAILURE - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "api_error" - - -async def test_flow_import_already_configured( - hass: HomeAssistant, - config_entry: MockConfigEntry, - emoncms_client: AsyncMock, -) -> None: - """Test we abort import data set when entry is already configured.""" - config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=YAML, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - USER_INPUT = { CONF_URL: "http://1.1.1.1", CONF_API_KEY: "my_api_key", diff --git a/tests/components/emoncms/test_sensor.py b/tests/components/emoncms/test_sensor.py index a7bc8059287..2d976f483b3 100644 --- a/tests/components/emoncms/test_sensor.py +++ b/tests/components/emoncms/test_sensor.py @@ -7,12 +7,9 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.emoncms.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import EMONCMS_FAILURE, get_feed @@ -20,56 +17,6 @@ from .conftest import EMONCMS_FAILURE, get_feed from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_deprecated_yaml( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import from yaml config.""" - - await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}" - ) - - -async def test_yaml_with_template( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config_with_template: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import a yaml config with a value_template parameter.""" - - await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config_with_template) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"remove_value_template_{DOMAIN}" - ) - - -async def test_yaml_no_include_only_feed_id( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - emoncms_yaml_config_no_include_only_feed_id: ConfigType, - emoncms_client: AsyncMock, -) -> None: - """Test an issue is created when we import a yaml config without a include_only_feed_id parameter.""" - - await async_setup_component( - hass, SENSOR_DOMAIN, emoncms_yaml_config_no_include_only_feed_id - ) - await hass.async_block_till_done() - - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id=f"missing_include_only_feed_id_{DOMAIN}" - ) - - async def test_no_feed_selected( hass: HomeAssistant, config_no_feed: MockConfigEntry, From b3eb0301ae89ebcd6ccbe7e11f09dfc4bb7d8e49 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Apr 2025 14:37:45 +0200 Subject: [PATCH 091/116] Remove YAML import in Point (#142627) --- homeassistant/components/point/__init__.py | 77 +------------------ homeassistant/components/point/config_flow.py | 4 - tests/components/point/test_config_flow.py | 14 ---- 3 files changed, 4 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 0f90bd75c9d..5c782bb3304 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -5,30 +5,14 @@ import logging from aiohttp import ClientError, ClientResponseError, web from pypoint import PointSession -import voluptuous as vol from homeassistant.components import webhook -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import ( - aiohttp_client, - config_entry_oauth2_flow, - config_validation as cv, -) +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from . import api from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK @@ -40,59 +24,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Minut Point component.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Point", - }, - ) - - if not hass.config_entries.async_entries(DOMAIN): - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - conf[CONF_CLIENT_ID], - conf[CONF_CLIENT_SECRET], - ), - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Set up Minut Point from a config entry.""" diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index b26ade8b725..426177a1849 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -24,10 +24,6 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Return logger.""" return logging.getLogger(__name__) - async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult: - """Handle import from YAML.""" - return await self.async_step_user() - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index ea003af86c7..4de1c9a4583 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -10,7 +10,6 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.point.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -157,16 +156,3 @@ async def test_reauthentication_flow( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected assert old_entry.unique_id == expected_unique_id - - -async def test_import_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "pick_implementation" From 83c3275054f3126b06e0d1ae8e05cea172235115 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Apr 2025 14:40:08 +0200 Subject: [PATCH 092/116] Remove deprecated state attributes in seventeentrack (#142622) --- .../components/seventeentrack/sensor.py | 32 +---------- .../components/seventeentrack/test_sensor.py | 54 +------------------ 2 files changed, 3 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index b0f9d6cd2bd..c6fd7942655 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -2,11 +2,8 @@ from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,15 +11,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SeventeenTrackCoordinator -from .const import ( - ATTR_INFO_TEXT, - ATTR_PACKAGES, - ATTR_STATUS, - ATTR_TIMESTAMP, - ATTR_TRACKING_NUMBER, - ATTRIBUTION, - DOMAIN, -) +from .const import ATTRIBUTION, DOMAIN async def async_setup_entry( @@ -81,22 +70,3 @@ class SeventeenTrackSummarySensor(SeventeenTrackSensor): def native_value(self) -> StateType: """Return the state of the sensor.""" return self.coordinator.data.summary[self._status]["quantity"] - - # This has been deprecated in 2024.8, will be removed in 2025.2 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - packages = self.coordinator.data.summary[self._status]["packages"] - return { - ATTR_PACKAGES: [ - { - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_TIMESTAMP: package.timestamp, - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } - for package in packages - ] - } diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index 5367fabba9e..11ed9904eae 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -4,20 +4,12 @@ from __future__ import annotations from unittest.mock import AsyncMock -from freezegun.api import FrozenDateTimeFactory from pyseventeentrack.errors import SeventeenTrackError from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from . import goto_future, init_integration -from .conftest import ( - DEFAULT_SUMMARY, - DEFAULT_SUMMARY_LENGTH, - NEW_SUMMARY_DATA, - VALID_PLATFORM_CONFIG_FULL, - get_package, -) +from . import init_integration +from .conftest import DEFAULT_SUMMARY, get_package from tests.common import MockConfigEntry @@ -78,38 +70,6 @@ async def test_package_error( assert hass.states.get("sensor.17track_package_friendly_name_1") is None -async def test_summary_correctly_updated( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_seventeentrack: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure summary entities are not duplicated.""" - package = get_package(status=30) - mock_seventeentrack.return_value.profile.packages.return_value = [package] - - await init_integration(hass, mock_config_entry) - - assert len(hass.states.async_entity_ids()) == DEFAULT_SUMMARY_LENGTH - - state_ready_picked = hass.states.get("sensor.17track_ready_to_be_picked_up") - assert state_ready_picked is not None - assert len(state_ready_picked.attributes["packages"]) == 1 - - mock_seventeentrack.return_value.profile.packages.return_value = [] - mock_seventeentrack.return_value.profile.summary.return_value = NEW_SUMMARY_DATA - - await goto_future(hass, freezer) - - assert len(hass.states.async_entity_ids()) == len(NEW_SUMMARY_DATA) - for state in hass.states.async_all(): - assert state.state == "1" - - state_ready_picked = hass.states.get("sensor.17track_ready_to_be_picked_up") - assert state_ready_picked is not None - assert len(state_ready_picked.attributes["packages"]) == 0 - - async def test_summary_error( hass: HomeAssistant, mock_seventeentrack: AsyncMock, @@ -129,13 +89,3 @@ async def test_summary_error( assert ( hass.states.get("sensor.seventeentrack_packages_ready_to_be_picked_up") is None ) - - -async def test_non_valid_platform_config( - hass: HomeAssistant, mock_seventeentrack: AsyncMock -) -> None: - """Test if login fails.""" - mock_seventeentrack.return_value.profile.login.return_value = False - assert await async_setup_component(hass, "sensor", VALID_PLATFORM_CONFIG_FULL) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 0 From 6d74a6aa19409a5d7124b9c9f7a623903a8b0a1a Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:01:55 +0200 Subject: [PATCH 093/116] Refactor homematicip_cloud connection (#139081) --- .../homematicip_cloud/alarm_control_panel.py | 6 +- .../homematicip_cloud/binary_sensor.py | 92 +++++++------- .../components/homematicip_cloud/button.py | 6 +- .../components/homematicip_cloud/climate.py | 46 +++---- .../components/homematicip_cloud/cover.py | 88 ++++++------- .../components/homematicip_cloud/entity.py | 10 +- .../components/homematicip_cloud/event.py | 2 +- .../components/homematicip_cloud/hap.py | 65 +++++++--- .../components/homematicip_cloud/light.py | 42 +++--- .../components/homematicip_cloud/lock.py | 10 +- .../homematicip_cloud/manifest.json | 2 +- .../components/homematicip_cloud/sensor.py | 120 +++++++++--------- .../components/homematicip_cloud/services.py | 48 +++---- .../components/homematicip_cloud/switch.py | 70 +++++----- .../components/homematicip_cloud/weather.py | 10 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/homematicip_cloud/conftest.py | 33 +++-- tests/components/homematicip_cloud/helper.py | 22 ++-- .../test_alarm_control_panel.py | 12 +- .../homematicip_cloud/test_climate.py | 60 ++++----- .../homematicip_cloud/test_cover.py | 64 +++++----- .../homematicip_cloud/test_device.py | 8 +- .../components/homematicip_cloud/test_hap.py | 67 +++++++--- .../components/homematicip_cloud/test_init.py | 20 +-- .../homematicip_cloud/test_light.py | 44 +++---- .../components/homematicip_cloud/test_lock.py | 8 +- .../homematicip_cloud/test_switch.py | 24 ++-- 28 files changed, 508 insertions(+), 475 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index d5b084644e3..af57d8b0cd0 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -82,15 +82,15 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._home.set_security_zones_activation(False, False) + await self._home.set_security_zones_activation_async(False, False) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._home.set_security_zones_activation(False, True) + await self._home.set_security_zones_activation_async(False, True) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._home.set_security_zones_activation(True, True) + await self._home.set_security_zones_activation_async(True, True) async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index f0cd3732718..e135e95634d 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -4,31 +4,31 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncAccelerationSensor, - AsyncContactInterface, - AsyncDevice, - AsyncFullFlushContactInterface, - AsyncFullFlushContactInterface6, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPluggableMainsFailureSurveillance, - AsyncPresenceDetectorIndoor, - AsyncRainSensor, - AsyncRotaryHandleSensor, - AsyncShutterContact, - AsyncShutterContactMagnetic, - AsyncSmokeDetector, - AsyncTiltVibrationSensor, - AsyncWaterSensor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, - AsyncWiredInput32, -) -from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup from homematicip.base.enums import SmokeDetectorAlarmType, WindowState +from homematicip.device import ( + AccelerationSensor, + ContactInterface, + Device, + FullFlushContactInterface, + FullFlushContactInterface6, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PluggableMainsFailureSurveillance, + PresenceDetectorIndoor, + RainSensor, + RotaryHandleSensor, + ShutterContact, + ShutterContactMagnetic, + SmokeDetector, + TiltVibrationSensor, + WaterSensor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, + WiredInput32, +) +from homematicip.group import SecurityGroup, SecurityZoneGroup from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -82,66 +82,60 @@ async def async_setup_entry( hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)] for device in hap.home.devices: - if isinstance(device, AsyncAccelerationSensor): + if isinstance(device, AccelerationSensor): entities.append(HomematicipAccelerationSensor(hap, device)) - if isinstance(device, AsyncTiltVibrationSensor): + if isinstance(device, TiltVibrationSensor): entities.append(HomematicipTiltVibrationSensor(hap, device)) - if isinstance(device, AsyncWiredInput32): + if isinstance(device, WiredInput32): entities.extend( HomematicipMultiContactInterface(hap, device, channel=channel) for channel in range(1, 33) ) - elif isinstance(device, AsyncFullFlushContactInterface6): + elif isinstance(device, FullFlushContactInterface6): entities.extend( HomematicipMultiContactInterface(hap, device, channel=channel) for channel in range(1, 7) ) - elif isinstance( - device, (AsyncContactInterface, AsyncFullFlushContactInterface) - ): + elif isinstance(device, (ContactInterface, FullFlushContactInterface)): entities.append(HomematicipContactInterface(hap, device)) if isinstance( device, - (AsyncShutterContact, AsyncShutterContactMagnetic), + (ShutterContact, ShutterContactMagnetic), ): entities.append(HomematicipShutterContact(hap, device)) - if isinstance(device, AsyncRotaryHandleSensor): + if isinstance(device, RotaryHandleSensor): entities.append(HomematicipShutterContact(hap, device, True)) if isinstance( device, ( - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, ), ): entities.append(HomematicipMotionDetector(hap, device)) - if isinstance(device, AsyncPluggableMainsFailureSurveillance): + if isinstance(device, PluggableMainsFailureSurveillance): entities.append( HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) ) - if isinstance(device, AsyncPresenceDetectorIndoor): + if isinstance(device, PresenceDetectorIndoor): entities.append(HomematicipPresenceDetector(hap, device)) - if isinstance(device, AsyncSmokeDetector): + if isinstance(device, SmokeDetector): entities.append(HomematicipSmokeDetector(hap, device)) - if isinstance(device, AsyncWaterSensor): + if isinstance(device, WaterSensor): entities.append(HomematicipWaterDetector(hap, device)) - if isinstance( - device, (AsyncRainSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipRainSensor(hap, device)) - if isinstance( - device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipStormSensor(hap, device)) entities.append(HomematicipSunshineSensor(hap, device)) - if isinstance(device, AsyncDevice) and device.lowBat is not None: + if isinstance(device, Device) and device.lowBat is not None: entities.append(HomematicipBatterySensor(hap, device)) for group in hap.home.groups: - if isinstance(group, AsyncSecurityGroup): + if isinstance(group, SecurityGroup): entities.append(HomematicipSecuritySensorGroup(hap, device=group)) - elif isinstance(group, AsyncSecurityZoneGroup): + elif isinstance(group, SecurityZoneGroup): entities.append(HomematicipSecurityZoneSensorGroup(hap, device=group)) async_add_entities(entities) diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index fedc271714c..0d70ad53d54 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homematicip.aio.device import AsyncWallMountedGarageDoorController +from homematicip.device import WallMountedGarageDoorController from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry @@ -25,7 +25,7 @@ async def async_setup_entry( async_add_entities( HomematicipGarageDoorControllerButton(hap, device) for device in hap.home.devices - if isinstance(device, AsyncWallMountedGarageDoorController) + if isinstance(device, WallMountedGarageDoorController) ) @@ -39,4 +39,4 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti async def async_press(self) -> None: """Handle the button press.""" - await self._device.send_start_impulse() + await self._device.send_start_impulse_async() diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 35bd18ff438..0952f17d3ec 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -4,16 +4,15 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, -) -from homematicip.aio.group import AsyncHeatingGroup from homematicip.base.enums import AbsenceType -from homematicip.device import Switch +from homematicip.device import ( + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, + Switch, +) from homematicip.functionalHomes import IndoorClimateHome -from homematicip.group import HeatingCoolingProfile +from homematicip.group import HeatingCoolingProfile, HeatingGroup from homeassistant.components.climate import ( PRESET_AWAY, @@ -65,7 +64,7 @@ async def async_setup_entry( async_add_entities( HomematicipHeatingGroup(hap, device) for device in hap.home.groups - if isinstance(device, AsyncHeatingGroup) + if isinstance(device, HeatingGroup) ) @@ -82,7 +81,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: + def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None: """Initialize heating group.""" device.modelType = "HmIP-Heating-Group" super().__init__(hap, device) @@ -214,7 +213,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): return if self.min_temp <= temperature <= self.max_temp: - await self._device.set_point_temperature(temperature) + await self._device.set_point_temperature_async(temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -222,23 +221,23 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): return if hvac_mode == HVACMode.AUTO: - await self._device.set_control_mode(HMIP_AUTOMATIC_CM) + await self._device.set_control_mode_async(HMIP_AUTOMATIC_CM) else: - await self._device.set_control_mode(HMIP_MANUAL_CM) + await self._device.set_control_mode_async(HMIP_MANUAL_CM) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" if self._device.boostMode and preset_mode != PRESET_BOOST: - await self._device.set_boost(False) + await self._device.set_boost_async(False) if preset_mode == PRESET_BOOST: - await self._device.set_boost() + await self._device.set_boost_async() if preset_mode == PRESET_ECO: - await self._device.set_control_mode(HMIP_ECO_CM) + await self._device.set_control_mode_async(HMIP_ECO_CM) if preset_mode in self._device_profile_names: profile_idx = self._get_profile_idx_by_name(preset_mode) if self._device.controlMode != HMIP_AUTOMATIC_CM: await self.async_set_hvac_mode(HVACMode.AUTO) - await self._device.set_active_profile(profile_idx) + await self._device.set_active_profile_async(profile_idx) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -332,20 +331,15 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): @property def _first_radiator_thermostat( self, - ) -> ( - AsyncHeatingThermostat - | AsyncHeatingThermostatCompact - | AsyncHeatingThermostatEvo - | None - ): + ) -> HeatingThermostat | HeatingThermostatCompact | HeatingThermostatEvo | None: """Return the first radiator thermostat from the hmip heating group.""" for device in self._device.devices: if isinstance( device, ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, ), ): return device diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 27a84abb572..317024658e1 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -4,16 +4,16 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBlindModule, - AsyncDinRailBlind4, - AsyncFullFlushBlind, - AsyncFullFlushShutter, - AsyncGarageDoorModuleTormatic, - AsyncHoermannDrivesModule, -) -from homematicip.aio.group import AsyncExtendedLinkedShutterGroup from homematicip.base.enums import DoorCommand, DoorState +from homematicip.device import ( + BlindModule, + DinRailBlind4, + FullFlushBlind, + FullFlushShutter, + GarageDoorModuleTormatic, + HoermannDrivesModule, +) +from homematicip.group import ExtendedLinkedShutterGroup from homeassistant.components.cover import ( ATTR_POSITION, @@ -45,23 +45,21 @@ async def async_setup_entry( entities: list[HomematicipGenericEntity] = [ HomematicipCoverShutterGroup(hap, group) for group in hap.home.groups - if isinstance(group, AsyncExtendedLinkedShutterGroup) + if isinstance(group, ExtendedLinkedShutterGroup) ] for device in hap.home.devices: - if isinstance(device, AsyncBlindModule): + if isinstance(device, BlindModule): entities.append(HomematicipBlindModule(hap, device)) - elif isinstance(device, AsyncDinRailBlind4): + elif isinstance(device, DinRailBlind4): entities.extend( HomematicipMultiCoverSlats(hap, device, channel=channel) for channel in range(1, 5) ) - elif isinstance(device, AsyncFullFlushBlind): + elif isinstance(device, FullFlushBlind): entities.append(HomematicipCoverSlats(hap, device)) - elif isinstance(device, AsyncFullFlushShutter): + elif isinstance(device, FullFlushShutter): entities.append(HomematicipCoverShutter(hap, device)) - elif isinstance( - device, (AsyncHoermannDrivesModule, AsyncGarageDoorModuleTormatic) - ): + elif isinstance(device, (HoermannDrivesModule, GarageDoorModuleTormatic)): entities.append(HomematicipGarageDoorModule(hap, device)) async_add_entities(entities) @@ -91,14 +89,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_primary_shading_level(primaryShadingLevel=level) + await self._device.set_primary_shading_level_async(primaryShadingLevel=level) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=level, ) @@ -112,37 +110,37 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_primary_shading_level( + await self._device.set_primary_shading_level_async( primaryShadingLevel=HMIP_COVER_OPEN ) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_primary_shading_level( + await self._device.set_primary_shading_level_async( primaryShadingLevel=HMIP_COVER_CLOSED ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.stop() + await self._device.stop_async() async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_OPEN, ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_secondary_shading_level( + await self._device.set_secondary_shading_level_async( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_CLOSED, ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.stop() + await self._device.stop_async() class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): @@ -176,7 +174,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_shutter_level(level, self._channel) + await self._device.set_shutter_level_async(level, self._channel) @property def is_closed(self) -> bool | None: @@ -190,15 +188,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_shutter_level(HMIP_COVER_OPEN, self._channel) + await self._device.set_shutter_level_async(HMIP_COVER_OPEN, self._channel) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_shutter_level(HMIP_COVER_CLOSED, self._channel) + await self._device.set_shutter_level_async(HMIP_COVER_CLOSED, self._channel) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.set_shutter_stop(self._channel) + await self._device.set_shutter_stop_async(self._channel) class HomematicipCoverShutter(HomematicipMultiCoverShutter, CoverEntity): @@ -238,23 +236,25 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_slats_level(slatsLevel=level, channelIndex=self._channel) + await self._device.set_slats_level_async( + slatsLevel=level, channelIndex=self._channel + ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_slats_level( + await self._device.set_slats_level_async( slatsLevel=HMIP_SLATS_OPEN, channelIndex=self._channel ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_slats_level( + await self._device.set_slats_level_async( slatsLevel=HMIP_SLATS_CLOSED, channelIndex=self._channel ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" - await self._device.set_shutter_stop(self._channel) + await self._device.set_shutter_stop_async(self._channel) class HomematicipCoverSlats(HomematicipMultiCoverSlats, CoverEntity): @@ -288,15 +288,15 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.send_door_command(DoorCommand.OPEN) + await self._device.send_door_command_async(DoorCommand.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.send_door_command(DoorCommand.CLOSE) + await self._device.send_door_command_async(DoorCommand.CLOSE) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._device.send_door_command(DoorCommand.STOP) + await self._device.send_door_command_async(DoorCommand.STOP) class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): @@ -335,35 +335,35 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_shutter_level(level) + await self._device.set_shutter_level_async(level) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_slats_level(level) + await self._device.set_slats_level_async(level) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.set_shutter_level(HMIP_COVER_OPEN) + await self._device.set_shutter_level_async(HMIP_COVER_OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.set_shutter_level(HMIP_COVER_CLOSED) + await self._device.set_shutter_level_async(HMIP_COVER_CLOSED) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the group if in motion.""" - await self._device.set_shutter_stop() + await self._device.set_shutter_stop_async() async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" - await self._device.set_slats_level(HMIP_SLATS_OPEN) + await self._device.set_slats_level_async(HMIP_SLATS_OPEN) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" - await self._device.set_slats_level(HMIP_SLATS_CLOSED) + await self._device.set_slats_level_async(HMIP_SLATS_CLOSED) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the group if in motion.""" - await self._device.set_shutter_stop() + await self._device.set_shutter_stop_async() diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 82d682b9910..41ccbb4b060 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -5,9 +5,9 @@ from __future__ import annotations import logging from typing import Any -from homematicip.aio.device import AsyncDevice -from homematicip.aio.group import AsyncGroup from homematicip.base.functionalChannels import FunctionalChannel +from homematicip.device import Device +from homematicip.group import Group from homeassistant.const import ATTR_ID from homeassistant.core import callback @@ -100,7 +100,7 @@ class HomematicipGenericEntity(Entity): def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" # Only physical devices should be HA devices. - if isinstance(self._device, AsyncDevice): + if isinstance(self._device, Device): return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device @@ -237,14 +237,14 @@ class HomematicipGenericEntity(Entity): """Return the state attributes of the generic entity.""" state_attr = {} - if isinstance(self._device, AsyncDevice): + if isinstance(self._device, Device): for attr, attr_key in DEVICE_ATTRIBUTES.items(): if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value state_attr[ATTR_IS_GROUP] = False - if isinstance(self._device, AsyncGroup): + if isinstance(self._device, Group): for attr, attr_key in GROUP_ATTRIBUTES.items(): if attr_value := getattr(self._device, attr, None): state_attr[attr_key] = attr_value diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index 654f56bb47f..47a5ff46224 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from homematicip.aio.device import Device +from homematicip.device import Device from homeassistant.components.event import ( EventDeviceClass, diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index db7fcb348c8..d55b98b8c18 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -7,15 +7,18 @@ from collections.abc import Callable import logging from typing import Any -from homematicip.aio.auth import AsyncAuth -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome +from homematicip.auth import Auth from homematicip.base.base_connection import HmipConnectionError from homematicip.base.enums import EventType +from homematicip.connection.connection_context import ConnectionContextBuilder +from homematicip.connection.rest_connection import RestConnection +import homeassistant from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.httpx_client import get_async_client from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN, PLATFORMS from .errors import HmipcConnectionError @@ -23,10 +26,25 @@ from .errors import HmipcConnectionError _LOGGER = logging.getLogger(__name__) +async def build_context_async( + hass: HomeAssistant, hapid: str | None, authtoken: str | None +): + """Create a HomematicIP context object.""" + ssl_ctx = homeassistant.util.ssl.get_default_context() + client_session = get_async_client(hass) + + return await ConnectionContextBuilder.build_context_async( + accesspoint_id=hapid, + auth_token=authtoken, + ssl_ctx=ssl_ctx, + httpx_client_session=client_session, + ) + + class HomematicipAuth: """Manages HomematicIP client registration.""" - auth: AsyncAuth + auth: Auth def __init__(self, hass: HomeAssistant, config: dict[str, str]) -> None: """Initialize HomematicIP Cloud client registration.""" @@ -46,27 +64,34 @@ class HomematicipAuth: async def async_checkbutton(self) -> bool: """Check blue butten has been pressed.""" try: - return await self.auth.isRequestAcknowledged() + return await self.auth.is_request_acknowledged() except HmipConnectionError: return False async def async_register(self): """Register client at HomematicIP.""" try: - authtoken = await self.auth.requestAuthToken() - await self.auth.confirmAuthToken(authtoken) + authtoken = await self.auth.request_auth_token() + await self.auth.confirm_auth_token(authtoken) except HmipConnectionError: return False return authtoken async def get_auth(self, hass: HomeAssistant, hapid, pin): """Create a HomematicIP access point object.""" - auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) + context = await build_context_async(hass, hapid, None) + connection = RestConnection( + context, + log_status_exceptions=False, + httpx_client_session=get_async_client(hass), + ) + # hass.loop + auth = Auth(connection, context.client_auth_token, hapid) + try: - await auth.init(hapid) - if pin: - auth.pin = pin - await auth.connectionRequest("HomeAssistant") + auth.set_pin(pin) + result = await auth.connection_request(hapid) + _LOGGER.debug("Connection request result: %s", result) except HmipConnectionError: return None return auth @@ -156,7 +181,7 @@ class HomematicipHAP: async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" - await self.home.get_current_state() + await self.home.get_current_state_async() self.update_all() def get_state_finished(self, future) -> None: @@ -187,8 +212,8 @@ class HomematicipHAP: retry_delay = 2 ** min(tries, 8) try: - await self.home.get_current_state() - hmip_events = await self.home.enable_events() + await self.home.get_current_state_async() + hmip_events = self.home.enable_events() tries = 0 await hmip_events except HmipConnectionError: @@ -219,7 +244,7 @@ class HomematicipHAP: self._ws_close_requested = True if self._retry_task is not None: self._retry_task.cancel() - await self.home.disable_events() + await self.home.disable_events_async() _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS @@ -246,17 +271,17 @@ class HomematicipHAP: name: str | None, ) -> AsyncHome: """Create a HomematicIP access point object.""" - home = AsyncHome(hass.loop, async_get_clientsession(hass)) + home = AsyncHome() home.name = name # Use the title of the config entry as title for the home. home.label = self.config_entry.title home.modelType = "HomematicIP Cloud Home" - home.set_auth_token(authtoken) try: - await home.init(hapid) - await home.get_current_state() + context = await build_context_async(hass, hapid, authtoken) + home.init_with_context(context, True, get_async_client(hass)) + await home.get_current_state_async() except HmipConnectionError as err: raise HmipcConnectionError from err home.on_update(self.async_update) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index ad946809fd4..338599b9a14 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -4,18 +4,18 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBrandDimmer, - AsyncBrandSwitchMeasuring, - AsyncBrandSwitchNotificationLight, - AsyncDimmer, - AsyncDinRailDimmer3, - AsyncFullFlushDimmer, - AsyncPluggableDimmer, - AsyncWiredDimmer3, -) from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState from homematicip.base.functionalChannels import NotificationLightChannel +from homematicip.device import ( + BrandDimmer, + BrandSwitchMeasuring, + BrandSwitchNotificationLight, + Dimmer, + DinRailDimmer3, + FullFlushDimmer, + PluggableDimmer, + WiredDimmer3, +) from packaging.version import Version from homeassistant.components.light import ( @@ -46,9 +46,9 @@ async def async_setup_entry( hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncBrandSwitchMeasuring): + if isinstance(device, BrandSwitchMeasuring): entities.append(HomematicipLightMeasuring(hap, device)) - elif isinstance(device, AsyncBrandSwitchNotificationLight): + elif isinstance(device, BrandSwitchNotificationLight): device_version = Version(device.firmwareVersion) entities.append(HomematicipLight(hap, device)) @@ -65,14 +65,14 @@ async def async_setup_entry( entity_class(hap, device, device.bottomLightChannelIndex, "Bottom") ) - elif isinstance(device, (AsyncWiredDimmer3, AsyncDinRailDimmer3)): + elif isinstance(device, (WiredDimmer3, DinRailDimmer3)): entities.extend( HomematicipMultiDimmer(hap, device, channel=channel) for channel in range(1, 4) ) elif isinstance( device, - (AsyncDimmer, AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer), + (Dimmer, PluggableDimmer, BrandDimmer, FullFlushDimmer), ): entities.append(HomematicipDimmer(hap, device)) @@ -96,11 +96,11 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - await self._device.turn_on() + await self._device.turn_on_async() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self._device.turn_off() + await self._device.turn_off_async() class HomematicipLightMeasuring(HomematicipLight): @@ -141,15 +141,15 @@ class HomematicipMultiDimmer(HomematicipGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the dimmer on.""" if ATTR_BRIGHTNESS in kwargs: - await self._device.set_dim_level( + await self._device.set_dim_level_async( kwargs[ATTR_BRIGHTNESS] / 255.0, self._channel ) else: - await self._device.set_dim_level(1, self._channel) + await self._device.set_dim_level_async(1, self._channel) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the dimmer off.""" - await self._device.set_dim_level(0, self._channel) + await self._device.set_dim_level_async(0, self._channel) class HomematicipDimmer(HomematicipMultiDimmer, LightEntity): @@ -239,7 +239,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): dim_level = brightness / 255.0 transition = kwargs.get(ATTR_TRANSITION, 0.5) - await self._device.set_rgb_dim_level_with_time( + await self._device.set_rgb_dim_level_with_time_async( channelIndex=self._channel, rgb=simple_rgb_color, dimLevel=dim_level, @@ -252,7 +252,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity): simple_rgb_color = self._func_channel.simpleRGBColorState transition = kwargs.get(ATTR_TRANSITION, 0.5) - await self._device.set_rgb_dim_level_with_time( + await self._device.set_rgb_dim_level_with_time_async( channelIndex=self._channel, rgb=simple_rgb_color, dimLevel=0.0, diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index a054e95a80d..04461682f8d 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from typing import Any -from homematicip.aio.device import AsyncDoorLockDrive from homematicip.base.enums import LockState, MotorState +from homematicip.device import DoorLockDrive from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry @@ -45,7 +45,7 @@ async def async_setup_entry( async_add_entities( HomematicipDoorLockDrive(hap, device) for device in hap.home.devices - if isinstance(device, AsyncDoorLockDrive) + if isinstance(device, DoorLockDrive) ) @@ -75,17 +75,17 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): @handle_errors async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - return await self._device.set_lock_state(LockState.LOCKED) + return await self._device.set_lock_state_async(LockState.LOCKED) @handle_errors async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - return await self._device.set_lock_state(LockState.UNLOCKED) + return await self._device.set_lock_state_async(LockState.UNLOCKED) @handle_errors async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - return await self._device.set_lock_state(LockState.OPEN) + return await self._device.set_lock_state_async(LockState.OPEN) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 414ba37709e..b1d631e7e6a 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==1.1.7"] + "requirements": ["homematicip==2.0.0"] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 0280f5bc7d5..bddac78df1c 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -5,39 +5,39 @@ from __future__ import annotations from collections.abc import Callable from typing import Any -from homematicip.aio.device import ( - AsyncBrandSwitchMeasuring, - AsyncEnergySensorsInterface, - AsyncFloorTerminalBlock6, - AsyncFloorTerminalBlock10, - AsyncFloorTerminalBlock12, - AsyncFullFlushSwitchMeasuring, - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, - AsyncHomeControlAccessPoint, - AsyncLightSensor, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPassageDetector, - AsyncPlugableSwitchMeasuring, - AsyncPresenceDetectorIndoor, - AsyncRoomControlDeviceAnalog, - AsyncTemperatureDifferenceSensor2, - AsyncTemperatureHumiditySensorDisplay, - AsyncTemperatureHumiditySensorOutdoor, - AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, - AsyncWiredFloorTerminalBlock12, -) from homematicip.base.enums import FunctionalChannelType, ValveState from homematicip.base.functionalChannels import ( FloorTerminalBlockMechanicChannel, FunctionalChannel, ) +from homematicip.device import ( + BrandSwitchMeasuring, + EnergySensorsInterface, + FloorTerminalBlock6, + FloorTerminalBlock10, + FloorTerminalBlock12, + FullFlushSwitchMeasuring, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, + HomeControlAccessPoint, + LightSensor, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PassageDetector, + PlugableSwitchMeasuring, + PresenceDetectorIndoor, + RoomControlDeviceAnalog, + TemperatureDifferenceSensor2, + TemperatureHumiditySensorDisplay, + TemperatureHumiditySensorOutdoor, + TemperatureHumiditySensorWithoutDisplay, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, + WiredFloorTerminalBlock12, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -102,14 +102,14 @@ async def async_setup_entry( hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncHomeControlAccessPoint): + if isinstance(device, HomeControlAccessPoint): entities.append(HomematicipAccesspointDutyCycle(hap, device)) if isinstance( device, ( - AsyncHeatingThermostat, - AsyncHeatingThermostatCompact, - AsyncHeatingThermostatEvo, + HeatingThermostat, + HeatingThermostatCompact, + HeatingThermostatEvo, ), ): entities.append(HomematicipHeatingThermostat(hap, device)) @@ -117,55 +117,53 @@ async def async_setup_entry( if isinstance( device, ( - AsyncTemperatureHumiditySensorDisplay, - AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncTemperatureHumiditySensorOutdoor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, + TemperatureHumiditySensorDisplay, + TemperatureHumiditySensorWithoutDisplay, + TemperatureHumiditySensorOutdoor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, ), ): entities.append(HomematicipTemperatureSensor(hap, device)) entities.append(HomematicipHumiditySensor(hap, device)) - elif isinstance(device, (AsyncRoomControlDeviceAnalog,)): + elif isinstance(device, (RoomControlDeviceAnalog,)): entities.append(HomematicipTemperatureSensor(hap, device)) if isinstance( device, ( - AsyncLightSensor, - AsyncMotionDetectorIndoor, - AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, - AsyncPresenceDetectorIndoor, - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, + LightSensor, + MotionDetectorIndoor, + MotionDetectorOutdoor, + MotionDetectorPushButton, + PresenceDetectorIndoor, + WeatherSensor, + WeatherSensorPlus, + WeatherSensorPro, ), ): entities.append(HomematicipIlluminanceSensor(hap, device)) if isinstance( device, ( - AsyncPlugableSwitchMeasuring, - AsyncBrandSwitchMeasuring, - AsyncFullFlushSwitchMeasuring, + PlugableSwitchMeasuring, + BrandSwitchMeasuring, + FullFlushSwitchMeasuring, ), ): entities.append(HomematicipPowerSensor(hap, device)) entities.append(HomematicipEnergySensor(hap, device)) - if isinstance( - device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) - ): + if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipWindspeedSensor(hap, device)) - if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): + if isinstance(device, (WeatherSensorPlus, WeatherSensorPro)): entities.append(HomematicipTodayRainSensor(hap, device)) - if isinstance(device, AsyncPassageDetector): + if isinstance(device, PassageDetector): entities.append(HomematicipPassageDetectorDeltaCounter(hap, device)) - if isinstance(device, AsyncTemperatureDifferenceSensor2): + if isinstance(device, TemperatureDifferenceSensor2): entities.append(HomematicpTemperatureExternalSensorCh1(hap, device)) entities.append(HomematicpTemperatureExternalSensorCh2(hap, device)) entities.append(HomematicpTemperatureExternalSensorDelta(hap, device)) - if isinstance(device, AsyncEnergySensorsInterface): + if isinstance(device, EnergySensorsInterface): for ch in get_channels_from_device( device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL ): @@ -194,10 +192,10 @@ async def async_setup_entry( if isinstance( device, ( - AsyncFloorTerminalBlock6, - AsyncFloorTerminalBlock10, - AsyncFloorTerminalBlock12, - AsyncWiredFloorTerminalBlock12, + FloorTerminalBlock6, + FloorTerminalBlock10, + FloorTerminalBlock12, + WiredFloorTerminalBlock12, ), ): entities.extend( diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 7a4dfd4916f..4518c7736eb 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -5,10 +5,10 @@ from __future__ import annotations import logging from pathlib import Path -from homematicip.aio.device import AsyncSwitchMeasuring -from homematicip.aio.group import AsyncHeatingGroup -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome from homematicip.base.helpers import handle_config +from homematicip.device import SwitchMeasuring +from homematicip.group import HeatingGroup import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE @@ -233,10 +233,10 @@ async def _async_activate_eco_mode_with_duration( if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_absence_with_duration(duration) + await home.activate_absence_with_duration_async(duration) else: for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_duration(duration) + await hap.home.activate_absence_with_duration_async(duration) async def _async_activate_eco_mode_with_period( @@ -247,10 +247,10 @@ async def _async_activate_eco_mode_with_period( if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_absence_with_period(endtime) + await home.activate_absence_with_period_async(endtime) else: for hap in hass.data[DOMAIN].values(): - await hap.home.activate_absence_with_period(endtime) + await hap.home.activate_absence_with_period_async(endtime) async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: @@ -260,30 +260,30 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.activate_vacation(endtime, temperature) + await home.activate_vacation_async(endtime, temperature) else: for hap in hass.data[DOMAIN].values(): - await hap.home.activate_vacation(endtime, temperature) + await hap.home.activate_vacation_async(endtime, temperature) async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate eco mode.""" if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.deactivate_absence() + await home.deactivate_absence_async() else: for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_absence() + await hap.home.deactivate_absence_async() async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None: """Service to deactivate vacation.""" if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.deactivate_vacation() + await home.deactivate_vacation_async() else: for hap in hass.data[DOMAIN].values(): - await hap.home.deactivate_vacation() + await hap.home.deactivate_vacation_async() async def _set_active_climate_profile( @@ -297,12 +297,12 @@ async def _set_active_climate_profile( if entity_id_list != "all": for entity_id in entity_id_list: group = hap.hmip_device_by_entity_id.get(entity_id) - if group and isinstance(group, AsyncHeatingGroup): - await group.set_active_profile(climate_profile_index) + if group and isinstance(group, HeatingGroup): + await group.set_active_profile_async(climate_profile_index) else: for group in hap.home.groups: - if isinstance(group, AsyncHeatingGroup): - await group.set_active_profile(climate_profile_index) + if isinstance(group, HeatingGroup): + await group.set_active_profile_async(climate_profile_index) async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None: @@ -323,7 +323,7 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N path = Path(config_path) config_file = path / file_name - json_state = await hap.home.download_configuration() + json_state = await hap.home.download_configuration_async() json_state = handle_config(json_state, anonymize) config_file.write_text(json_state, encoding="utf8") @@ -337,12 +337,12 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall) if entity_id_list != "all": for entity_id in entity_id_list: device = hap.hmip_device_by_entity_id.get(entity_id) - if device and isinstance(device, AsyncSwitchMeasuring): - await device.reset_energy_counter() + if device and isinstance(device, SwitchMeasuring): + await device.reset_energy_counter_async() else: for device in hap.home.devices: - if isinstance(device, AsyncSwitchMeasuring): - await device.reset_energy_counter() + if isinstance(device, SwitchMeasuring): + await device.reset_energy_counter_async() async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall): @@ -351,10 +351,10 @@ async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall if hapid := service.data.get(ATTR_ACCESSPOINT_ID): if home := _get_home(hass, hapid): - await home.set_cooling(cooling) + await home.set_cooling_async(cooling) else: for hap in hass.data[DOMAIN].values(): - await hap.home.set_cooling(cooling) + await hap.home.set_cooling_async(cooling) def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None: diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index a9aa1c664d7..2de02fb22a5 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,23 +4,23 @@ from __future__ import annotations from typing import Any -from homematicip.aio.device import ( - AsyncBrandSwitch2, - AsyncBrandSwitchMeasuring, - AsyncDinRailSwitch, - AsyncDinRailSwitch4, - AsyncFullFlushInputSwitch, - AsyncFullFlushSwitchMeasuring, - AsyncHeatingSwitch2, - AsyncMultiIOBox, - AsyncOpenCollector8Module, - AsyncPlugableSwitch, - AsyncPlugableSwitchMeasuring, - AsyncPrintedCircuitBoardSwitch2, - AsyncPrintedCircuitBoardSwitchBattery, - AsyncWiredSwitch8, +from homematicip.device import ( + BrandSwitch2, + BrandSwitchMeasuring, + DinRailSwitch, + DinRailSwitch4, + FullFlushInputSwitch, + FullFlushSwitchMeasuring, + HeatingSwitch2, + MultiIOBox, + OpenCollector8Module, + PlugableSwitch, + PlugableSwitchMeasuring, + PrintedCircuitBoardSwitch2, + PrintedCircuitBoardSwitchBattery, + WiredSwitch8, ) -from homematicip.aio.group import AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup +from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -42,26 +42,24 @@ async def async_setup_entry( entities: list[HomematicipGenericEntity] = [ HomematicipGroupSwitch(hap, group) for group in hap.home.groups - if isinstance(group, (AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup)) + if isinstance(group, (ExtendedLinkedSwitchingGroup, SwitchingGroup)) ] for device in hap.home.devices: - if isinstance(device, AsyncBrandSwitchMeasuring): + if isinstance(device, BrandSwitchMeasuring): # BrandSwitchMeasuring inherits PlugableSwitchMeasuring # This entity is implemented in the light platform and will # not be added in the switch platform pass - elif isinstance( - device, (AsyncPlugableSwitchMeasuring, AsyncFullFlushSwitchMeasuring) - ): + elif isinstance(device, (PlugableSwitchMeasuring, FullFlushSwitchMeasuring)): entities.append(HomematicipSwitchMeasuring(hap, device)) - elif isinstance(device, AsyncWiredSwitch8): + elif isinstance(device, WiredSwitch8): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 9) ) - elif isinstance(device, AsyncDinRailSwitch): + elif isinstance(device, DinRailSwitch): entities.append(HomematicipMultiSwitch(hap, device, channel=1)) - elif isinstance(device, AsyncDinRailSwitch4): + elif isinstance(device, DinRailSwitch4): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 5) @@ -69,13 +67,13 @@ async def async_setup_entry( elif isinstance( device, ( - AsyncPlugableSwitch, - AsyncPrintedCircuitBoardSwitchBattery, - AsyncFullFlushInputSwitch, + PlugableSwitch, + PrintedCircuitBoardSwitchBattery, + FullFlushInputSwitch, ), ): entities.append(HomematicipSwitch(hap, device)) - elif isinstance(device, AsyncOpenCollector8Module): + elif isinstance(device, OpenCollector8Module): entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) for channel in range(1, 9) @@ -83,10 +81,10 @@ async def async_setup_entry( elif isinstance( device, ( - AsyncBrandSwitch2, - AsyncPrintedCircuitBoardSwitch2, - AsyncHeatingSwitch2, - AsyncMultiIOBox, + BrandSwitch2, + PrintedCircuitBoardSwitch2, + HeatingSwitch2, + MultiIOBox, ), ): entities.extend( @@ -119,11 +117,11 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.turn_on(self._channel) + await self._device.turn_on_async(self._channel) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.turn_off(self._channel) + await self._device.turn_off_async(self._channel) class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): @@ -168,11 +166,11 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the group on.""" - await self._device.turn_on() + await self._device.turn_on_async() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the group off.""" - await self._device.turn_off() + await self._device.turn_off_async() class HomematicipSwitchMeasuring(HomematicipSwitch): diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 1125c73f8d4..78e86ec652c 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -2,12 +2,8 @@ from __future__ import annotations -from homematicip.aio.device import ( - AsyncWeatherSensor, - AsyncWeatherSensorPlus, - AsyncWeatherSensorPro, -) from homematicip.base.enums import WeatherCondition +from homematicip.device import WeatherSensor, WeatherSensorPlus, WeatherSensorPro from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -59,9 +55,9 @@ async def async_setup_entry( hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, AsyncWeatherSensorPro): + if isinstance(device, WeatherSensorPro): entities.append(HomematicipWeatherSensorPro(hap, device)) - elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)): + elif isinstance(device, (WeatherSensor, WeatherSensorPlus)): entities.append(HomematicipWeatherSensor(hap, device)) entities.append(HomematicipHomeWeather(hap)) diff --git a/requirements_all.txt b/requirements_all.txt index dba70d2e8c2..2ddf002774e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1163,7 +1163,7 @@ home-assistant-frontend==20250411.0 home-assistant-intents==2025.3.28 # homeassistant.components.homematicip_cloud -homematicip==1.1.7 +homematicip==2.0.0 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 116b2b5dc2f..6eb3dfd60a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -993,7 +993,7 @@ home-assistant-frontend==20250411.0 home-assistant-intents==2025.3.28 # homeassistant.components.homematicip_cloud -homematicip==1.1.7 +homematicip==2.0.0 # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index ad3957fea69..8672dfedd13 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -1,11 +1,11 @@ """Initializer helpers for HomematicIP fake server.""" -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch -from homematicip.aio.auth import AsyncAuth -from homematicip.aio.connection import AsyncConnection -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome +from homematicip.auth import Auth from homematicip.base.enums import WeatherCondition, WeatherDayTime +from homematicip.connection.rest_connection import RestConnection import pytest from homeassistant.components.homematicip_cloud import ( @@ -30,16 +30,14 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture(name="mock_connection") -def mock_connection_fixture() -> AsyncConnection: +def mock_connection_fixture() -> RestConnection: """Return a mocked connection.""" - connection = MagicMock(spec=AsyncConnection) + connection = AsyncMock(spec=RestConnection) - def _rest_call_side_effect(path, body=None): + def _rest_call_side_effect(path, body=None, custom_header=None): return path, body - connection._rest_call.side_effect = _rest_call_side_effect - connection.api_call = AsyncMock(return_value=True) - connection.init = AsyncMock(side_effect=True) + connection.async_post.side_effect = _rest_call_side_effect return connection @@ -107,7 +105,7 @@ async def mock_hap_with_service_fixture( def simple_mock_home_fixture(): """Return a simple mocked connection.""" - mock_home = Mock( + mock_home = AsyncMock( spec=AsyncHome, name="Demo", devices=[], @@ -128,6 +126,8 @@ def simple_mock_home_fixture(): dutyCycle=88, connected=True, currentAPVersion="2.0.36", + init_async=AsyncMock(), + get_current_state_async=AsyncMock(), ) with patch( @@ -144,18 +144,15 @@ def mock_connection_init_fixture(): with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.init", - return_value=None, - ), - patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth.init", + "homeassistant.components.homematicip_cloud.hap.AsyncHome.init_async", return_value=None, + new_callable=AsyncMock, ), ): yield @pytest.fixture(name="simple_mock_auth") -def simple_mock_auth_fixture() -> AsyncAuth: +def simple_mock_auth_fixture() -> Auth: """Return a simple AsyncAuth Mock.""" - return Mock(spec=AsyncAuth, pin=HAPPIN, create=True) + return AsyncMock(spec=Auth, pin=HAPPIN, create=True) diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index 80081123519..78c03c6847c 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -4,15 +4,15 @@ import json from typing import Any from unittest.mock import Mock, patch -from homematicip.aio.class_maps import ( +from homematicip.async_home import AsyncHome +from homematicip.base.homematicip_object import HomeMaticIPObject +from homematicip.class_maps import ( TYPE_CLASS_MAP, TYPE_GROUP_MAP, TYPE_SECURITY_EVENT_MAP, ) -from homematicip.aio.device import AsyncDevice -from homematicip.aio.group import AsyncGroup -from homematicip.aio.home import AsyncHome -from homematicip.base.homematicip_object import HomeMaticIPObject +from homematicip.device import Device +from homematicip.group import Group from homematicip.home import Home from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN @@ -49,9 +49,9 @@ def get_and_check_entity_basics( hmip_device = mock_hap.hmip_device_by_entity_id.get(entity_id) if hmip_device: - if isinstance(hmip_device, AsyncDevice): + if isinstance(hmip_device, Device): assert ha_state.attributes[ATTR_IS_GROUP] is False - elif isinstance(hmip_device, AsyncGroup): + elif isinstance(hmip_device, Group): assert ha_state.attributes[ATTR_IS_GROUP] return ha_state, hmip_device @@ -174,12 +174,12 @@ class HomeTemplate(Home): def init_home(self): """Init template with json.""" self.init_json_state = self._cleanup_json(json.loads(FIXTURE_DATA)) - self.update_home(json_state=self.init_json_state, clearConfig=True) + self.update_home(json_state=self.init_json_state, clear_config=True) return self - def update_home(self, json_state, clearConfig: bool = False): + def update_home(self, json_state, clear_config: bool = False): """Update home and ensure that mocks are created.""" - result = super().update_home(json_state, clearConfig) + result = super().update_home(json_state, clear_config) self._generate_mocks() return result @@ -193,7 +193,7 @@ class HomeTemplate(Home): self.groups = [_get_mock(group) for group in self.groups] - def download_configuration(self): + async def download_configuration_async(self): """Return the initial json config.""" return self.init_json_state diff --git a/tests/components/homematicip_cloud/test_alarm_control_panel.py b/tests/components/homematicip_cloud/test_alarm_control_panel.py index 094308862f6..853660ceac6 100644 --- a/tests/components/homematicip_cloud/test_alarm_control_panel.py +++ b/tests/components/homematicip_cloud/test_alarm_control_panel.py @@ -1,6 +1,6 @@ """Tests for HomematicIP Cloud alarm control panel.""" -from homematicip.aio.home import AsyncHome +from homematicip.async_home import AsyncHome from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, @@ -73,7 +73,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_away", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (True, True) await _async_manipulate_security_zones( hass, home, internal_active=True, external_active=True @@ -83,7 +83,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_home", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, True) await _async_manipulate_security_zones(hass, home, external_active=True) assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_HOME @@ -91,7 +91,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_disarm", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, False) await _async_manipulate_security_zones(hass, home) assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED @@ -99,7 +99,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_away", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (True, True) await _async_manipulate_security_zones( hass, home, internal_active=True, external_active=True, alarm_triggered=True @@ -109,7 +109,7 @@ async def test_hmip_alarm_control_panel( await hass.services.async_call( "alarm_control_panel", "alarm_arm_home", {"entity_id": entity_id}, blocking=True ) - assert home.mock_calls[-1][0] == "set_security_zones_activation" + assert home.mock_calls[-1][0] == "set_security_zones_activation_async" assert home.mock_calls[-1][1] == (False, True) await _async_manipulate_security_zones( hass, home, external_active=True, alarm_triggered=True diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index d4711440288..c39d4fa2d99 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -83,7 +83,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_point_temperature" + assert hmip_device.mock_calls[-1][0] == "set_point_temperature_async" assert hmip_device.mock_calls[-1][1] == (22.5,) await async_manipulate_test_data(hass, hmip_device, "actualTemperature", 22.5) ha_state = hass.states.get(entity_id) @@ -96,7 +96,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -109,7 +109,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("AUTOMATIC",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTO") ha_state = hass.states.get(entity_id) @@ -122,7 +122,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_boost" + assert hmip_device.mock_calls[-1][0] == "set_boost_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "boostMode", True) ha_state = hass.states.get(entity_id) @@ -135,7 +135,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 11 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "boostMode", False) ha_state = hass.states.get(entity_id) @@ -176,7 +176,7 @@ async def test_hmip_heating_group_heat( ) assert len(hmip_device.mock_calls) == service_call_counter + 18 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) mock_hap.home.get_functionalHome( @@ -194,7 +194,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 20 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -208,7 +208,7 @@ async def test_hmip_heating_group_heat( ) assert len(hmip_device.mock_calls) == service_call_counter + 23 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) hmip_device.activeProfile = hmip_device.profiles[0] await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTOMATIC") @@ -235,7 +235,7 @@ async def test_hmip_heating_group_heat( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 25 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("ECO",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "ECO") ha_state = hass.states.get(entity_id) @@ -293,7 +293,7 @@ async def test_hmip_heating_group_cool( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("MANUAL",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "MANUAL") ha_state = hass.states.get(entity_id) @@ -306,7 +306,7 @@ async def test_hmip_heating_group_cool( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][0] == "set_control_mode_async" assert hmip_device.mock_calls[-1][1] == ("AUTOMATIC",) await async_manipulate_test_data(hass, hmip_device, "controlMode", "AUTO") ha_state = hass.states.get(entity_id) @@ -320,7 +320,7 @@ async def test_hmip_heating_group_cool( ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (4,) hmip_device.activeProfile = hmip_device.profiles[4] @@ -373,7 +373,7 @@ async def test_hmip_heating_group_cool( ) assert len(hmip_device.mock_calls) == service_call_counter + 17 - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (4,) @@ -531,7 +531,7 @@ async def test_hmip_climate_services( {"duration": 60, "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_duration" + assert home.mock_calls[-1][0] == "activate_absence_with_duration_async" assert home.mock_calls[-1][1] == (60,) assert len(home._connection.mock_calls) == 1 @@ -541,7 +541,7 @@ async def test_hmip_climate_services( {"duration": 60}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_duration" + assert home.mock_calls[-1][0] == "activate_absence_with_duration_async" assert home.mock_calls[-1][1] == (60,) assert len(home._connection.mock_calls) == 2 @@ -551,7 +551,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_period" + assert home.mock_calls[-1][0] == "activate_absence_with_period_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0),) assert len(home._connection.mock_calls) == 3 @@ -561,7 +561,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00"}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_absence_with_period" + assert home.mock_calls[-1][0] == "activate_absence_with_period_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0),) assert len(home._connection.mock_calls) == 4 @@ -571,7 +571,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "temperature": 18.5, "accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_vacation" + assert home.mock_calls[-1][0] == "activate_vacation_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0), 18.5) assert len(home._connection.mock_calls) == 5 @@ -581,7 +581,7 @@ async def test_hmip_climate_services( {"endtime": "2019-02-17 14:00", "temperature": 18.5}, blocking=True, ) - assert home.mock_calls[-1][0] == "activate_vacation" + assert home.mock_calls[-1][0] == "activate_vacation_async" assert home.mock_calls[-1][1] == (datetime.datetime(2019, 2, 17, 14, 0), 18.5) assert len(home._connection.mock_calls) == 6 @@ -591,14 +591,14 @@ async def test_hmip_climate_services( {"accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "deactivate_absence" + assert home.mock_calls[-1][0] == "deactivate_absence_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 7 await hass.services.async_call( "homematicip_cloud", "deactivate_eco_mode", blocking=True ) - assert home.mock_calls[-1][0] == "deactivate_absence" + assert home.mock_calls[-1][0] == "deactivate_absence_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 8 @@ -608,14 +608,14 @@ async def test_hmip_climate_services( {"accesspoint_id": HAPID}, blocking=True, ) - assert home.mock_calls[-1][0] == "deactivate_vacation" + assert home.mock_calls[-1][0] == "deactivate_vacation_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 9 await hass.services.async_call( "homematicip_cloud", "deactivate_vacation", blocking=True ) - assert home.mock_calls[-1][0] == "deactivate_vacation" + assert home.mock_calls[-1][0] == "deactivate_vacation_async" assert home.mock_calls[-1][1] == () assert len(home._connection.mock_calls) == 10 @@ -646,7 +646,7 @@ async def test_hmip_set_home_cooling_mode( {"accesspoint_id": HAPID, "cooling": False}, blocking=True, ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] == (False,) assert len(home._connection.mock_calls) == 1 @@ -656,14 +656,14 @@ async def test_hmip_set_home_cooling_mode( {"accesspoint_id": HAPID, "cooling": True}, blocking=True, ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] assert len(home._connection.mock_calls) == 2 await hass.services.async_call( "homematicip_cloud", "set_home_cooling_mode", blocking=True ) - assert home.mock_calls[-1][0] == "set_cooling" + assert home.mock_calls[-1][0] == "set_cooling_async" assert home.mock_calls[-1][1] assert len(home._connection.mock_calls) == 3 @@ -703,9 +703,9 @@ async def test_hmip_heating_group_services( {"climate_profile_index": 2, "entity_id": "climate.badezimmer"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) - assert len(hmip_device._connection.mock_calls) == 2 + assert len(hmip_device._connection.mock_calls) == 1 await hass.services.async_call( "homematicip_cloud", @@ -713,6 +713,6 @@ async def test_hmip_heating_group_services( {"climate_profile_index": 2, "entity_id": "all"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_active_profile" + assert hmip_device.mock_calls[-1][0] == "set_active_profile_async" assert hmip_device.mock_calls[-1][1] == (1,) - assert len(hmip_device._connection.mock_calls) == 4 + assert len(hmip_device._connection.mock_calls) == 2 diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index bcafa689172..aa104da0546 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -47,7 +47,7 @@ async def test_hmip_cover_shutter( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) ha_state = hass.states.get(entity_id) @@ -61,7 +61,7 @@ async def test_hmip_cover_shutter( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0.5, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -72,7 +72,7 @@ async def test_hmip_cover_shutter( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 1) ha_state = hass.states.get(entity_id) @@ -83,7 +83,7 @@ async def test_hmip_cover_shutter( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None) @@ -115,7 +115,7 @@ async def test_hmip_cover_slats( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) @@ -131,7 +131,7 @@ async def test_hmip_cover_slats( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -143,7 +143,7 @@ async def test_hmip_cover_slats( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) ha_state = hass.states.get(entity_id) @@ -155,7 +155,7 @@ async def test_hmip_cover_slats( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", None) @@ -195,7 +195,7 @@ async def test_hmip_multi_cover_slats( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0, channel=4) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0, channel=4) @@ -211,7 +211,7 @@ async def test_hmip_multi_cover_slats( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5, channel=4) ha_state = hass.states.get(entity_id) @@ -223,7 +223,7 @@ async def test_hmip_multi_cover_slats( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 6 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1, channel=4) ha_state = hass.states.get(entity_id) @@ -235,7 +235,7 @@ async def test_hmip_multi_cover_slats( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == (4,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", None, channel=4) @@ -271,7 +271,7 @@ async def test_hmip_blind_module( "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level_async" assert hmip_device.mock_calls[-1][2] == { "primaryShadingLevel": 0.94956, "secondaryShadingLevel": 0, @@ -284,7 +284,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level_async" assert hmip_device.mock_calls[-1][2] == {"primaryShadingLevel": 0} ha_state = hass.states.get(entity_id) @@ -308,7 +308,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 8 - assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_primary_shading_level_async" assert hmip_device.mock_calls[-1][2] == {"primaryShadingLevel": 0.5} ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.OPEN @@ -325,7 +325,7 @@ async def test_hmip_blind_module( ) assert len(hmip_device.mock_calls) == service_call_counter + 12 - assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level" + assert hmip_device.mock_calls[-1][0] == "set_secondary_shading_level_async" assert hmip_device.mock_calls[-1][2] == { "primaryShadingLevel": 1, "secondaryShadingLevel": 1, @@ -340,14 +340,14 @@ async def test_hmip_blind_module( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 13 - assert hmip_device.mock_calls[-1][0] == "stop" + assert hmip_device.mock_calls[-1][0] == "stop_async" assert hmip_device.mock_calls[-1][1] == () await hass.services.async_call( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 14 - assert hmip_device.mock_calls[-1][0] == "stop" + assert hmip_device.mock_calls[-1][0] == "stop_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "secondaryShadingLevel", None) @@ -382,7 +382,7 @@ async def test_hmip_garage_door_tormatic( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) @@ -393,7 +393,7 @@ async def test_hmip_garage_door_tormatic( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) @@ -404,7 +404,7 @@ async def test_hmip_garage_door_tormatic( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) @@ -431,7 +431,7 @@ async def test_hmip_garage_door_hoermann( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) @@ -442,7 +442,7 @@ async def test_hmip_garage_door_hoermann( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) @@ -453,7 +453,7 @@ async def test_hmip_garage_door_hoermann( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command" + assert hmip_device.mock_calls[-1][0] == "send_door_command_async" assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) @@ -478,7 +478,7 @@ async def test_hmip_cover_shutter_group( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) ha_state = hass.states.get(entity_id) @@ -492,7 +492,7 @@ async def test_hmip_cover_shutter_group( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (0.5,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -503,7 +503,7 @@ async def test_hmip_cover_shutter_group( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][0] == "set_shutter_level_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 1) ha_state = hass.states.get(entity_id) @@ -514,7 +514,7 @@ async def test_hmip_cover_shutter_group( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None) @@ -553,7 +553,7 @@ async def test_hmip_cover_slats_group( ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (0,) await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) @@ -569,7 +569,7 @@ async def test_hmip_cover_slats_group( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (0.5,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) ha_state = hass.states.get(entity_id) @@ -581,7 +581,7 @@ async def test_hmip_cover_slats_group( "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 7 - assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][0] == "set_slats_level_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) ha_state = hass.states.get(entity_id) @@ -593,5 +593,5 @@ async def test_hmip_cover_slats_group( "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 9 - assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop_async" assert hmip_device.mock_calls[-1][1] == () diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 5ec37d8d8f5..3d3dd170ddd 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -257,14 +257,14 @@ async def test_hmip_reset_energy_counter_services( {"entity_id": "switch.pc"}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 2 + assert hmip_device.mock_calls[-1][0] == "reset_energy_counter_async" + assert len(hmip_device._connection.mock_calls) == 1 await hass.services.async_call( "homematicip_cloud", "reset_energy_counter", {"entity_id": "all"}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 4 + assert hmip_device.mock_calls[-1][0] == "reset_energy_counter_async" + assert len(hmip_device._connection.mock_calls) == 2 async def test_hmip_multi_area_device( diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index ded1bf88292..8f56c2e0b99 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -2,8 +2,9 @@ from unittest.mock import Mock, patch -from homematicip.aio.auth import AsyncAuth +from homematicip.auth import Auth from homematicip.base.base_connection import HmipConnectionError +from homematicip.connection.connection_context import ConnectionContext import pytest from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN @@ -48,13 +49,13 @@ async def test_auth_auth_check_and_register(hass: HomeAssistant) -> None: config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - hmip_auth.auth = Mock(spec=AsyncAuth) + hmip_auth.auth = Mock(spec=Auth) with ( - patch.object(hmip_auth.auth, "isRequestAcknowledged", return_value=True), - patch.object(hmip_auth.auth, "requestAuthToken", return_value="ABC"), + patch.object(hmip_auth.auth, "is_request_acknowledged", return_value=True), + patch.object(hmip_auth.auth, "request_auth_token", return_value="ABC"), patch.object( hmip_auth.auth, - "confirmAuthToken", + "confirm_auth_token", ), ): assert await hmip_auth.async_checkbutton() @@ -65,13 +66,13 @@ async def test_auth_auth_check_and_register_with_exception(hass: HomeAssistant) """Test auth client registration.""" config = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - hmip_auth.auth = Mock(spec=AsyncAuth) + hmip_auth.auth = Mock(spec=Auth) with ( patch.object( - hmip_auth.auth, "isRequestAcknowledged", side_effect=HmipConnectionError + hmip_auth.auth, "is_request_acknowledged", side_effect=HmipConnectionError ), patch.object( - hmip_auth.auth, "requestAuthToken", side_effect=HmipConnectionError + hmip_auth.auth, "request_auth_token", side_effect=HmipConnectionError ), ): assert not await hmip_auth.async_checkbutton() @@ -128,6 +129,10 @@ async def test_hap_reset_unloads_entry_if_setup( assert hass.data[HMIPC_DOMAIN] == {} +@patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), +) async def test_hap_create( hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home ) -> None: @@ -140,6 +145,10 @@ async def test_hap_create( assert await hap.async_setup() +@patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), +) async def test_hap_create_exception( hass: HomeAssistant, hmip_config_entry: MockConfigEntry, mock_connection_init ) -> None: @@ -150,14 +159,14 @@ async def test_hap_create_exception( assert hap with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", side_effect=Exception, ): assert not await hap.async_setup() with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", side_effect=HmipConnectionError, ), pytest.raises(ConfigEntryNotReady), @@ -171,9 +180,15 @@ async def test_auth_create(hass: HomeAssistant, simple_mock_auth) -> None: hmip_auth = HomematicipAuth(hass, config) assert hmip_auth - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert await hmip_auth.async_setup() await hass.async_block_till_done() @@ -184,16 +199,28 @@ async def test_auth_create_exception(hass: HomeAssistant, simple_mock_auth) -> N """Mock AsyncAuth to execute get_auth.""" config = {HMIPC_HAPID: HAPID, HMIPC_PIN: HAPPIN, HMIPC_NAME: "hmip"} hmip_auth = HomematicipAuth(hass, config) - simple_mock_auth.connectionRequest.side_effect = HmipConnectionError + simple_mock_auth.connection_request.side_effect = HmipConnectionError assert hmip_auth - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert not await hmip_auth.async_setup() - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncAuth", - return_value=simple_mock_auth, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.Auth", + return_value=simple_mock_auth, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert not await hmip_auth.get_auth(hass, HAPID, HAPPIN) diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index 07c53248d92..a3578baa9aa 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch from homematicip.base.base_connection import HmipConnectionError +from homematicip.connection.connection_context import ConnectionContext from homeassistant.components.homematicip_cloud.const import ( CONF_ACCESSPOINT, @@ -105,9 +106,15 @@ async def test_load_entry_fails_due_to_connection_error( """Test load entry fails due to connection error.""" hmip_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", - side_effect=HmipConnectionError, + with ( + patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", + side_effect=HmipConnectionError, + ), + patch( + "homeassistant.components.homematicip_cloud.hap.ConnectionContextBuilder.build_context_async", + return_value=ConnectionContext(), + ), ): assert await async_setup_component(hass, HMIPC_DOMAIN, {}) @@ -123,12 +130,9 @@ async def test_load_entry_fails_due_to_generic_exception( with ( patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state", + "homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state_async", side_effect=Exception, ), - patch( - "homematicip.aio.connection.AsyncConnection.init", - ), ): assert await async_setup_component(hass, HMIPC_DOMAIN, {}) @@ -175,7 +179,7 @@ async def test_hmip_dump_hap_config_services( "homematicip_cloud", "dump_hap_config", {"anonymize": True}, blocking=True ) home = mock_hap_with_service.home - assert home.mock_calls[-1][0] == "download_configuration" + assert home.mock_calls[-1][0] == "download_configuration_async" assert home.mock_calls assert write_mock.mock_calls diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index c0717e81e0d..48d9beccacc 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -54,7 +54,7 @@ async def test_hmip_light( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) @@ -68,7 +68,7 @@ async def test_hmip_light( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) @@ -104,7 +104,7 @@ async def test_hmip_notification_light( {"entity_id": entity_id, "brightness_pct": "100", "transition": 100}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "rgb": "RED", @@ -130,7 +130,7 @@ async def test_hmip_notification_light( {"entity_id": entity_id, "hs_color": hs_color}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "dimLevel": 0.0392156862745098, @@ -157,7 +157,7 @@ async def test_hmip_notification_light( "light", "turn_off", {"entity_id": entity_id, "transition": 100}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 11 - assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time" + assert hmip_device.mock_calls[-1][0] == "set_rgb_dim_level_with_time_async" assert hmip_device.mock_calls[-1][2] == { "channelIndex": 2, "dimLevel": 0.0, @@ -294,7 +294,7 @@ async def test_hmip_dimmer( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -304,7 +304,7 @@ async def test_hmip_dimmer( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1.0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1) ha_state = hass.states.get(entity_id) @@ -318,7 +318,7 @@ async def test_hmip_dimmer( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0) ha_state = hass.states.get(entity_id) @@ -355,7 +355,7 @@ async def test_hmip_light_measuring( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) @@ -369,7 +369,7 @@ async def test_hmip_light_measuring( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -400,7 +400,7 @@ async def test_hmip_wired_multi_dimmer( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -410,7 +410,7 @@ async def test_hmip_wired_multi_dimmer( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=1) ha_state = hass.states.get(entity_id) @@ -424,7 +424,7 @@ async def test_hmip_wired_multi_dimmer( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=1) ha_state = hass.states.get(entity_id) @@ -459,7 +459,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 1) await hass.services.async_call( @@ -469,7 +469,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=1) ha_state = hass.states.get(entity_id) @@ -483,7 +483,7 @@ async def test_hmip_din_rail_dimmer_3_channel1( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 1) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=1) ha_state = hass.states.get(entity_id) @@ -518,7 +518,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 2) await hass.services.async_call( @@ -528,7 +528,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 2) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=2) ha_state = hass.states.get(entity_id) @@ -542,7 +542,7 @@ async def test_hmip_din_rail_dimmer_3_channel2( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 2) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=2) ha_state = hass.states.get(entity_id) @@ -577,7 +577,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( await hass.services.async_call( "light", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (1, 3) await hass.services.async_call( @@ -587,7 +587,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( blocking=True, ) assert len(hmip_device.mock_calls) == service_call_counter + 2 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0.39215686274509803, 3) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 1, channel=3) ha_state = hass.states.get(entity_id) @@ -601,7 +601,7 @@ async def test_hmip_din_rail_dimmer_3_channel3( "light", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 4 - assert hmip_device.mock_calls[-1][0] == "set_dim_level" + assert hmip_device.mock_calls[-1][0] == "set_dim_level_async" assert hmip_device.mock_calls[-1][1] == (0, 3) await async_manipulate_test_data(hass, hmip_device, "dimLevel", 0, channel=3) ha_state = hass.states.get(entity_id) diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py index cb8a0188639..dd581cce044 100644 --- a/tests/components/homematicip_cloud/test_lock.py +++ b/tests/components/homematicip_cloud/test_lock.py @@ -50,7 +50,7 @@ async def test_hmip_doorlockdrive( {"entity_id": entity_id}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.OPEN,) await hass.services.async_call( @@ -59,7 +59,7 @@ async def test_hmip_doorlockdrive( {"entity_id": entity_id}, blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.LOCKED,) await hass.services.async_call( @@ -69,7 +69,7 @@ async def test_hmip_doorlockdrive( blocking=True, ) - assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][0] == "set_lock_state_async" assert hmip_device.mock_calls[-1][1] == (HomematicLockState.UNLOCKED,) await async_manipulate_test_data( @@ -96,7 +96,7 @@ async def test_hmip_doorlockdrive_handle_errors( test_devices=[entity_name] ) with patch( - "homematicip.aio.device.AsyncDoorLockDrive.set_lock_state", + "homematicip.device.DoorLockDrive.set_lock_state_async", return_value={ "errorCode": "INVALID_NUMBER_PARAMETER_VALUE", "minValue": 0.0, diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py index 54cdd632d03..bd7952025bc 100644 --- a/tests/components/homematicip_cloud/test_switch.py +++ b/tests/components/homematicip_cloud/test_switch.py @@ -42,7 +42,7 @@ async def test_hmip_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -52,7 +52,7 @@ async def test_hmip_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -81,7 +81,7 @@ async def test_hmip_switch_input( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -91,7 +91,7 @@ async def test_hmip_switch_input( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -120,7 +120,7 @@ async def test_hmip_switch_measuring( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -130,7 +130,7 @@ async def test_hmip_switch_measuring( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) @@ -158,7 +158,7 @@ async def test_hmip_group_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -168,7 +168,7 @@ async def test_hmip_group_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -208,7 +208,7 @@ async def test_hmip_multi_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) @@ -218,7 +218,7 @@ async def test_hmip_multi_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -259,7 +259,7 @@ async def test_hmip_wired_multi_switch( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off" + assert hmip_device.mock_calls[-1][0] == "turn_off_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) @@ -269,7 +269,7 @@ async def test_hmip_wired_multi_switch( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on" + assert hmip_device.mock_calls[-1][0] == "turn_on_async" assert hmip_device.mock_calls[-1][1] == (1,) await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) From 6a95abb831c448557e78bced421c83e75beea3ed Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 14 Apr 2025 06:14:00 -0700 Subject: [PATCH 094/116] Add effects translation/icon for Demo light (#142862) --- homeassistant/components/demo/icons.json | 11 +++++++++++ homeassistant/components/demo/light.py | 6 +++++- homeassistant/components/demo/strings.json | 11 +++++++++++ tests/components/demo/test_light.py | 4 ++-- tests/components/google_assistant/test_smart_home.py | 12 ++++++------ 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json index eafcbb9161a..9a076f47a2d 100644 --- a/homeassistant/components/demo/icons.json +++ b/homeassistant/components/demo/icons.json @@ -45,6 +45,17 @@ } } }, + "light": { + "bed_light": { + "state_attributes": { + "effect": { + "state": { + "rainbow": "mdi:looks" + } + } + } + } + }, "number": { "volume": { "default": "mdi:volume-high" diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index c00f2b42828..25a7b46bfb6 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( ATTR_WHITE, DEFAULT_MAX_KELVIN, DEFAULT_MIN_KELVIN, + EFFECT_OFF, ColorMode, LightEntity, LightEntityFeature, @@ -28,7 +29,7 @@ from . import DOMAIN LIGHT_COLORS = [(56, 86), (345, 75)] -LIGHT_EFFECT_LIST = ["rainbow", "none"] +LIGHT_EFFECT_LIST = ["rainbow", EFFECT_OFF] LIGHT_TEMPS = [4166, 2631] @@ -48,6 +49,7 @@ async def async_setup_entry( available=True, effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0], + translation_key="bed_light", device_name="Bed Light", state=False, unique_id="light_1", @@ -119,8 +121,10 @@ class DemoLight(LightEntity): rgbw_color: tuple[int, int, int, int] | None = None, rgbww_color: tuple[int, int, int, int, int] | None = None, supported_color_modes: set[ColorMode] | None = None, + translation_key: str | None = None, ) -> None: """Initialize the light.""" + self._attr_translation_key = translation_key self._available = True self._brightness = brightness self._ct = ct or random.choice(LIGHT_TEMPS) diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index da72b33d3ca..d40d3f56a6a 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -78,6 +78,17 @@ } } }, + "light": { + "bed_light": { + "state_attributes": { + "effect": { + "state": { + "rainbow": "Rainbow" + } + } + } + } + }, "select": { "speed": { "state": { diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py index b39b09d9307..af9006f97cc 100644 --- a/tests/components/demo/test_light.py +++ b/tests/components/demo/test_light.py @@ -80,7 +80,7 @@ async def test_state_attributes(hass: HomeAssistant) -> None: SERVICE_TURN_ON, { ATTR_ENTITY_ID: ENTITY_LIGHT, - ATTR_EFFECT: "none", + ATTR_EFFECT: "off", ATTR_COLOR_TEMP_KELVIN: 2500, }, blocking=True, @@ -90,7 +90,7 @@ async def test_state_attributes(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 2500 assert state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN) == 6535 assert state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN) == 2000 - assert state.attributes.get(ATTR_EFFECT) == "none" + assert state.attributes.get(ATTR_EFFECT) == "off" await hass.services.async_call( LIGHT_DOMAIN, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 3b43728988b..2dba083185d 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -235,11 +235,11 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ { "lang": "en", - "setting_synonym": ["none"], + "setting_synonym": ["off"], } ], }, @@ -356,9 +356,9 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ - {"lang": "en", "setting_synonym": ["none"]} + {"lang": "en", "setting_synonym": ["off"]} ], }, ], @@ -957,9 +957,9 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: ], }, { - "setting_name": "none", + "setting_name": "off", "setting_values": [ - {"lang": "en", "setting_synonym": ["none"]} + {"lang": "en", "setting_synonym": ["off"]} ], }, ], From c6abe1d1bb70c007aa0272b04ce745262a60cfb4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Apr 2025 15:28:02 +0200 Subject: [PATCH 095/116] Remove the word "node" from ESPHome texts (#142929) --- homeassistant/components/esphome/config_flow.py | 2 -- homeassistant/components/esphome/strings.json | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 52b8514088a..95304476fae 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -50,7 +50,6 @@ from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboar ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" -ESPHOME_URL = "https://esphome.io/" _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" @@ -96,7 +95,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema(fields), errors=errors, - description_placeholders={"esphome_url": ESPHOME_URL}, ) async def async_step_user( diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 437b9ac2098..197ae46077d 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -23,7 +23,7 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your [ESPHome]({esphome_url}) node." + "description": "Please enter connection settings of your ESPHome device." }, "authenticate": { "data": { @@ -47,8 +47,8 @@ "description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." }, "discovery_confirm": { - "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", - "title": "Discovered ESPHome node" + "description": "Do you want to add the device `{name}` to Home Assistant?", + "title": "Discovered ESPHome device" } }, "flow_title": "{name}" From bc683ce6ee86c15d2716dec175b7224787192e90 Mon Sep 17 00:00:00 2001 From: Lachlan Banks <87565923+lachlan443@users.noreply.github.com> Date: Mon, 14 Apr 2025 23:37:57 +1000 Subject: [PATCH 096/116] Bump qbittorrent-api to 2024.9.67 (#142588) --- homeassistant/components/qbittorrent/manifest.json | 2 +- homeassistant/components/qbittorrent/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index bd9897aa6ba..2f813e35557 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["qbittorrent-api==2024.2.59"] + "requirements": ["qbittorrent-api==2024.9.67"] } diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 23ec485fcd4..d565d2f7b5f 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -218,7 +218,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( key=SENSOR_TYPE_PAUSED_TORRENTS, translation_key="paused_torrents", value_fn=lambda coordinator: count_torrents_in_states( - coordinator, ["pausedDL", "pausedUP"] + coordinator, ["stoppedDL", "stoppedUP"] ), ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 2ddf002774e..52c55ce64a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2591,7 +2591,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qbittorrent -qbittorrent-api==2024.2.59 +qbittorrent-api==2024.9.67 # homeassistant.components.qbus qbusmqttapi==1.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6eb3dfd60a3..b09974e6369 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2104,7 +2104,7 @@ pyyardian==1.1.1 pyzerproc==0.4.8 # homeassistant.components.qbittorrent -qbittorrent-api==2024.2.59 +qbittorrent-api==2024.9.67 # homeassistant.components.qbus qbusmqttapi==1.3.0 From d44d07ffcf67f6cac1c839f9f7820240b10c633b Mon Sep 17 00:00:00 2001 From: Emily Love Watson Date: Mon, 14 Apr 2025 08:38:34 -0500 Subject: [PATCH 097/116] Kulersky refactor to new Bluetooth subsystem (#142309) Co-authored-by: J. Nick Koston --- .strict-typing | 1 + homeassistant/components/kulersky/__init__.py | 79 +++++-- .../components/kulersky/config_flow.py | 143 ++++++++++-- homeassistant/components/kulersky/const.py | 2 + homeassistant/components/kulersky/light.py | 75 ++---- .../components/kulersky/manifest.json | 6 + .../components/kulersky/strings.json | 16 +- homeassistant/generated/bluetooth.py | 4 + mypy.ini | 10 + tests/components/kulersky/test_config_flow.py | 213 ++++++++++++------ tests/components/kulersky/test_init.py | 65 ++++++ tests/components/kulersky/test_light.py | 57 ++--- 12 files changed, 495 insertions(+), 176 deletions(-) create mode 100644 tests/components/kulersky/test_init.py diff --git a/.strict-typing b/.strict-typing index 3e8ad0ddbaf..69d46958882 100644 --- a/.strict-typing +++ b/.strict-typing @@ -291,6 +291,7 @@ homeassistant.components.kaleidescape.* homeassistant.components.knocki.* homeassistant.components.knx.* homeassistant.components.kraken.* +homeassistant.components.kulersky.* homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* homeassistant.components.lamarzocco.* diff --git a/homeassistant/components/kulersky/__init__.py b/homeassistant/components/kulersky/__init__.py index 6c8037bdafc..b123a4cc035 100644 --- a/homeassistant/components/kulersky/__init__.py +++ b/homeassistant/components/kulersky/__init__.py @@ -1,21 +1,31 @@ """Kuler Sky lights integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +import logging -from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry +from homeassistant.const import CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN PLATFORMS = [Platform.LIGHT] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Kuler Sky from a config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if DATA_ADDRESSES not in hass.data[DOMAIN]: - hass.data[DOMAIN][DATA_ADDRESSES] = set() - + ble_device = async_ble_device_from_address( + hass, entry.data[CONF_ADDRESS], connectable=True + ) + if not ble_device: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -23,11 +33,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - # Stop discovery - unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None) - if unregister_discovery: - unregister_discovery() - - hass.data.pop(DOMAIN, None) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + # Version 1 was a single entry instance that started a bluetooth discovery + # thread to add devices. Version 2 has one config entry per device, and + # supports core bluetooth discovery + if config_entry.version == 1: + dev_reg = dr.async_get(hass) + devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id) + + if len(devices) == 0: + _LOGGER.error("Unable to migrate; No devices registered") + return False + + first_device = devices[0] + domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN] + address = next(iter(domain_identifiers))[1] + hass.config_entries.async_update_entry( + config_entry, + title=first_device.name or address, + data={CONF_ADDRESS: address}, + unique_id=address, + version=2, + ) + + # Create new config flows for the remaining devices + for device in devices[1:]: + domain_identifiers = [i for i in device.identifiers if i[0] == DOMAIN] + address = next(iter(domain_identifiers))[1] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: address}, + ) + ) + + _LOGGER.debug("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/kulersky/config_flow.py b/homeassistant/components/kulersky/config_flow.py index fca214dd9a3..f27d2ef0ea0 100644 --- a/homeassistant/components/kulersky/config_flow.py +++ b/homeassistant/components/kulersky/config_flow.py @@ -1,26 +1,143 @@ """Config flow for Kuler Sky.""" import logging +from typing import Any +from bluetooth_data_tools import human_readable_name import pykulersky +import voluptuous as vol -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, + async_last_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import DOMAIN, EXPECTED_SERVICE_UUID _LOGGER = logging.getLogger(__name__) -async def _async_has_devices(hass: HomeAssistant) -> bool: - """Return if there are devices that can be discovered.""" - # Check if there are any devices that can be discovered in the network. - try: - devices = await pykulersky.discover() - except pykulersky.PykulerskyException as exc: - _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc) - return False - return len(devices) > 0 +class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Kulersky.""" + VERSION = 2 -config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices) + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def async_step_integration_discovery( + self, discovery_info: dict[str, str] + ) -> ConfigFlowResult: + """Handle the integration discovery step. + + The old version of the integration used to have multiple + device in a single config entry. This is now deprecated. + The integration discovery step is used to create config + entries for each device beyond the first one. + """ + address: str = discovery_info[CONF_ADDRESS] + if service_info := async_last_service_info(self.hass, address): + title = human_readable_name(None, service_info.name, service_info.address) + else: + title = address + await self.async_set_unique_id(address) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=title, + data={CONF_ADDRESS: address}, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + discovery_info = self._discovered_devices[address] + local_name = human_readable_name( + None, discovery_info.name, discovery_info.address + ) + await self.async_set_unique_id( + discovery_info.address, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + kulersky_light = None + try: + kulersky_light = pykulersky.Light(discovery_info.address) + await kulersky_light.connect() + except pykulersky.PykulerskyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=local_name, + data={ + CONF_ADDRESS: discovery_info.address, + }, + ) + finally: + if kulersky_light: + await kulersky_light.disconnect() + + if discovery := self._discovery_info: + self._discovered_devices[discovery.address] = discovery + else: + current_addresses = self._async_current_ids() + for discovery in async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or EXPECTED_SERVICE_UUID not in discovery.service_uuids + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + if self._discovery_info: + data_schema = vol.Schema( + {vol.Required(CONF_ADDRESS): self._discovery_info.address} + ) + else: + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/kulersky/const.py b/homeassistant/components/kulersky/const.py index 8d0b4380bb3..c735b4774f9 100644 --- a/homeassistant/components/kulersky/const.py +++ b/homeassistant/components/kulersky/const.py @@ -4,3 +4,5 @@ DOMAIN = "kulersky" DATA_ADDRESSES = "addresses" DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription" + +EXPECTED_SERVICE_UUID = "8d96a001-0002-64c2-0001-9acc4838521c" diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index bcc3f32dceb..d6a45ed1ebe 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -2,12 +2,12 @@ from __future__ import annotations -from datetime import timedelta import logging from typing import Any import pykulersky +from homeassistant.components import bluetooth from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGBW_COLOR, @@ -15,18 +15,15 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DISCOVERY_INTERVAL = timedelta(seconds=60) - async def async_setup_entry( hass: HomeAssistant, @@ -34,32 +31,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Kuler sky light devices.""" - - async def discover(*args): - """Attempt to discover new lights.""" - lights = await pykulersky.discover() - - # Filter out already discovered lights - new_lights = [ - light - for light in lights - if light.address not in hass.data[DOMAIN][DATA_ADDRESSES] - ] - - new_entities = [] - for light in new_lights: - hass.data[DOMAIN][DATA_ADDRESSES].add(light.address) - new_entities.append(KulerskyLight(light)) - - async_add_entities(new_entities, update_before_add=True) - - # Start initial discovery - hass.async_create_task(discover()) - - # Perform recurring discovery of new devices - hass.data[DOMAIN][DATA_DISCOVERY_SUBSCRIPTION] = async_track_time_interval( - hass, discover, DISCOVERY_INTERVAL + ble_device = bluetooth.async_ble_device_from_address( + hass, config_entry.data[CONF_ADDRESS], connectable=True ) + entity = KulerskyLight( + config_entry.title, + config_entry.data[CONF_ADDRESS], + pykulersky.Light(ble_device), + ) + async_add_entities([entity], update_before_add=True) class KulerskyLight(LightEntity): @@ -71,37 +51,30 @@ class KulerskyLight(LightEntity): _attr_supported_color_modes = {ColorMode.RGBW} _attr_color_mode = ColorMode.RGBW - def __init__(self, light: pykulersky.Light) -> None: + def __init__(self, name: str, address: str, light: pykulersky.Light) -> None: """Initialize a Kuler Sky light.""" self._light = light - self._attr_unique_id = light.address + self._attr_unique_id = address self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, light.address)}, + identifiers={(DOMAIN, address)}, + connections={(CONNECTION_BLUETOOTH, address)}, manufacturer="Brightech", - name=light.name, + name=name, ) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - self.async_on_remove( - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self.async_will_remove_from_hass - ) - ) - - async def async_will_remove_from_hass(self, *args) -> None: + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" try: await self._light.disconnect() except pykulersky.PykulerskyException: _LOGGER.debug( - "Exception disconnected from %s", self._light.address, exc_info=True + "Exception disconnected from %s", self._attr_unique_id, exc_info=True ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if light is on.""" - return self.brightness > 0 + return self.brightness is not None and self.brightness > 0 async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" @@ -133,11 +106,13 @@ class KulerskyLight(LightEntity): rgbw = await self._light.get_color() except pykulersky.PykulerskyException as exc: if self._attr_available: - _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc) + _LOGGER.warning( + "Unable to connect to %s: %s", self._attr_unique_id, exc + ) self._attr_available = False return if self._attr_available is False: - _LOGGER.warning("Reconnected to %s", self._light.address) + _LOGGER.info("Reconnected to %s", self._attr_unique_id) self._attr_available = True brightness = max(rgbw) diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json index 49c4d4c1847..a838c47c698 100644 --- a/homeassistant/components/kulersky/manifest.json +++ b/homeassistant/components/kulersky/manifest.json @@ -1,8 +1,14 @@ { "domain": "kulersky", "name": "Kuler Sky", + "bluetooth": [ + { + "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c" + } + ], "codeowners": ["@emlove"], "config_flow": true, + "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/kulersky", "iot_class": "local_polling", "loggers": ["bleak", "pykulersky"], diff --git a/homeassistant/components/kulersky/strings.json b/homeassistant/components/kulersky/strings.json index ad8f0f41ae7..959d7d0690a 100644 --- a/homeassistant/components/kulersky/strings.json +++ b/homeassistant/components/kulersky/strings.json @@ -1,13 +1,23 @@ { "config": { "step": { - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" + "user": { + "data": { + "address": "[%key:common::config_flow::data::device%]" + } } }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" } } } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index da4b21cbba2..de7369b9479 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -404,6 +404,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "keymitt_ble", "local_name": "mib*", }, + { + "domain": "kulersky", + "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c", + }, { "domain": "lamarzocco", "local_name": "MICRA_*", diff --git a/mypy.ini b/mypy.ini index 685412e6e98..0e42a6c3594 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2666,6 +2666,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.kulersky.*] +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.lacrosse.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/kulersky/test_config_flow.py b/tests/components/kulersky/test_config_flow.py index a2f3949bd07..7615e94d2f0 100644 --- a/tests/components/kulersky/test_config_flow.py +++ b/tests/components/kulersky/test_config_flow.py @@ -1,105 +1,182 @@ """Test the Kuler Sky config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch import pykulersky -from homeassistant import config_entries +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.kulersky.config_flow import DOMAIN +from homeassistant.config_entries import ( + SOURCE_BLUETOOTH, + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_USER, +) +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device -async def test_flow_success(hass: HomeAssistant) -> None: - """Test we get the form.""" +KULERSKY_SERVICE_INFO = BluetoothServiceInfoBleak( + name="KulerLight", + manufacturer_data={}, + service_data={}, + service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="KulerLight", + manufacturer_data={}, + service_data={}, + service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "KulerLight"), + time=0, + connectable=True, + tx_power=-127, +) + +async def test_bluetooth_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - light = MagicMock(spec=pykulersky.Light) - light.address = "AA:BB:CC:11:22:33" - light.name = "Bedroom" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - return_value=[light], - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(return_value=AsyncMock())): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Kuler Sky" - assert result2["data"] == {} - - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } -async def test_flow_no_devices_found(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_integration_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_last_service_info", + return_value=KULERSKY_SERVICE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_integration_discovery_no_last_service_info(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AA:BB:CC:DD:EE:FF" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test the user manually setting up the integration.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_discovered_service_info", + return_value=[ + KULERSKY_SERVICE_INFO, + KULERSKY_SERVICE_INFO, + ], # Pass twice to test duplicate logic + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("pykulersky.Light", Mock(return_value=AsyncMock())): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "KulerLight (EEFF)" + assert result["data"] == { + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + } + + +async def test_user_setup_no_devices(hass: HomeAssistant) -> None: + """Test the user manually setting up the integration.""" + with patch( + "homeassistant.components.kulersky.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test a connection error trying to set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - return_value=[], - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(side_effect=pykulersky.PykulerskyException)): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" - assert len(mock_setup_entry.mock_calls) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" -async def test_flow_exceptions_caught(hass: HomeAssistant) -> None: - """Test we get the form.""" - +async def test_unexpected_error(hass: HomeAssistant) -> None: + """Test an unexpected error trying to set up.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=KULERSKY_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.kulersky.config_flow.pykulersky.discover", - side_effect=pykulersky.PykulerskyException("TEST"), - ), - patch( - "homeassistant.components.kulersky.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( + with patch("pykulersky.Light", Mock(side_effect=Exception)): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {}, + {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" - assert len(mock_setup_entry.mock_calls) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "unknown" diff --git a/tests/components/kulersky/test_init.py b/tests/components/kulersky/test_init.py new file mode 100644 index 00000000000..54c5f146a61 --- /dev/null +++ b/tests/components/kulersky/test_init.py @@ -0,0 +1,65 @@ +"""Tests for init methods.""" + +from homeassistant.components.kulersky.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_migrate_entry( + hass: HomeAssistant, +) -> None: + """Test migrate config entry from v1 to v2.""" + + mock_config_entry_v1 = MockConfigEntry( + version=1, + domain=DOMAIN, + title="KulerSky", + ) + + mock_config_entry_v1.add_to_hass(hass) + + dev_reg = dr.async_get(hass) + # Create device registry entries for old integration + dev_reg.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "AA:BB:CC:11:22:33")}, + name="KuLight 1", + ) + dev_reg.async_get_or_create( + config_entry_id=mock_config_entry_v1.entry_id, + identifiers={(DOMAIN, "AA:BB:CC:44:55:66")}, + name="KuLight 2", + ) + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry_v1.version == 2 + assert mock_config_entry_v1.unique_id == "AA:BB:CC:11:22:33" + assert mock_config_entry_v1.data == { + CONF_ADDRESS: "AA:BB:CC:11:22:33", + } + + +async def test_migrate_entry_no_devices_found( + hass: HomeAssistant, +) -> None: + """Test migrate config entry from v1 to v2.""" + + mock_config_entry_v1 = MockConfigEntry( + version=1, + domain=DOMAIN, + title="KulerSky", + ) + + mock_config_entry_v1.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v1.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v1.state is ConfigEntryState.MIGRATION_ERROR + assert mock_config_entry_v1.version == 1 diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index 230a2562282..bde60579af7 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -1,16 +1,13 @@ """Test the Kuler Sky lights.""" -from collections.abc import AsyncGenerator -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch +from bleak.backends.device import BLEDevice import pykulersky import pytest -from homeassistant.components.kulersky.const import ( - DATA_ADDRESSES, - DATA_DISCOVERY_SUBSCRIPTION, - DOMAIN, -) +from homeassistant.components.kulersky.const import DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -26,6 +23,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, + CONF_ADDRESS, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -37,26 +35,43 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.fixture +def mock_ble_device() -> Generator[MagicMock]: + """Mock BLEDevice.""" + with patch( + "homeassistant.components.kulersky.async_ble_device_from_address", + return_value=BLEDevice( + address="AA:BB:CC:11:22:33", name="Bedroom", rssi=-50, details={} + ), + ) as ble_device: + yield ble_device + + @pytest.fixture async def mock_entry() -> MockConfigEntry: """Create a mock light entity.""" - return MockConfigEntry(domain=DOMAIN) + return MockConfigEntry( + domain=DOMAIN, + data={CONF_ADDRESS: "AA:BB:CC:11:22:33"}, + title="Bedroom", + version=2, + ) @pytest.fixture async def mock_light( - hass: HomeAssistant, mock_entry: MockConfigEntry -) -> AsyncGenerator[MagicMock]: - """Create a mock light entity.""" - - light = MagicMock(spec=pykulersky.Light) + hass: HomeAssistant, mock_entry: MockConfigEntry, mock_ble_device: MagicMock +) -> Generator[AsyncMock]: + """Mock pykulersky light.""" + light = AsyncMock() light.address = "AA:BB:CC:11:22:33" light.name = "Bedroom" light.connect.return_value = True light.get_color.return_value = (0, 0, 0, 0) + with patch( - "homeassistant.components.kulersky.light.pykulersky.discover", - return_value=[light], + "pykulersky.Light", + return_value=light, ): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) @@ -67,7 +82,7 @@ async def mock_light( yield light -async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None: +async def test_init(hass: HomeAssistant, mock_light: AsyncMock) -> None: """Test platform setup.""" state = hass.states.get("light.bedroom") assert state.state == STATE_OFF @@ -83,24 +98,14 @@ async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None: ATTR_RGBW_COLOR: None, } - with patch.object(hass.loop, "stop"): - await hass.async_stop() - await hass.async_block_till_done() - - assert mock_light.disconnect.called - async def test_remove_entry( hass: HomeAssistant, mock_light: MagicMock, mock_entry: MockConfigEntry ) -> None: """Test platform setup.""" - assert hass.data[DOMAIN][DATA_ADDRESSES] == {"AA:BB:CC:11:22:33"} - assert DATA_DISCOVERY_SUBSCRIPTION in hass.data[DOMAIN] - await hass.config_entries.async_remove(mock_entry.entry_id) assert mock_light.disconnect.called - assert DOMAIN not in hass.data async def test_remove_entry_exceptions_caught( From aeca2842fe9459665e501d9d63b4a5b639659577 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Mon, 14 Apr 2025 15:39:44 +0200 Subject: [PATCH 098/116] Add WeHeat Flow sensors for pumps (#139390) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/weheat/const.py | 1 + homeassistant/components/weheat/icons.json | 6 + homeassistant/components/weheat/sensor.py | 20 ++++ homeassistant/components/weheat/strings.json | 6 + tests/components/weheat/conftest.py | 2 + .../weheat/snapshots/test_sensor.ambr | 110 ++++++++++++++++++ tests/components/weheat/test_sensor.py | 2 +- 7 files changed, 146 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py index ee9b77281e6..cd521afd2ea 100644 --- a/homeassistant/components/weheat/const.py +++ b/homeassistant/components/weheat/const.py @@ -25,3 +25,4 @@ LOGGER: Logger = getLogger(__package__) DISPLAY_PRECISION_WATTS = 0 DISPLAY_PRECISION_COP = 1 DISPLAY_PRECISION_WATER_TEMP = 1 +DISPLAY_PRECISION_FLOW = 1 diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index e7f54b478c6..c0955cd051d 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -42,6 +42,12 @@ "heat_pump_state": { "default": "mdi:state-machine" }, + "dhw_flow_volume": { + "default": "mdi:pump" + }, + "central_heating_flow_volume": { + "default": "mdi:pump" + }, "electricity_used": { "default": "mdi:flash" }, diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index d3b758e41eb..8ff80aeac08 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfPower, UnitOfTemperature, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,6 +25,7 @@ from homeassistant.helpers.typing import StateType from .const import ( DISPLAY_PRECISION_COP, + DISPLAY_PRECISION_FLOW, DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) @@ -161,6 +163,15 @@ SENSORS = [ native_unit_of_measurement=PERCENTAGE, value_fn=lambda status: status.compressor_percentage, ), + WeHeatSensorEntityDescription( + translation_key="central_heating_flow_volume", + key="central_heating_flow_volume", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_FLOW, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + value_fn=lambda status: status.central_heating_flow_volume, + ), ] DHW_SENSORS = [ @@ -182,6 +193,15 @@ DHW_SENSORS = [ suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, value_fn=lambda status: status.dhw_bottom_temperature, ), + WeHeatSensorEntityDescription( + translation_key="dhw_flow_volume", + key="dhw_flow_volume", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_FLOW, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + value_fn=lambda status: status.dhw_flow_volume, + ), ] ENERGY_SENSORS = [ diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 3959acad053..b02389e7f4f 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -86,6 +86,12 @@ "dhw_bottom_temperature": { "name": "DHW bottom temperature" }, + "dhw_flow_volume": { + "name": "DHW pump flow" + }, + "central_heating_flow_volume": { + "name": "Central heating pump flow" + }, "heat_pump_state": { "state": { "standby": "[%key:common::state::standby%]", diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index dbdeb0726dd..692792955fc 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -124,6 +124,8 @@ def mock_weheat_heat_pump_instance() -> MagicMock: mock_heat_pump_instance.energy_output = 56789 mock_heat_pump_instance.compressor_rpm = 4500 mock_heat_pump_instance.compressor_percentage = 100 + mock_heat_pump_instance.dhw_flow_volume = 1.12 + mock_heat_pump_instance.central_heating_flow_volume = 1.23 mock_heat_pump_instance.indoor_unit_water_pump_state = False mock_heat_pump_instance.indoor_unit_auxiliary_pump_state = False mock_heat_pump_instance.indoor_unit_dhw_valve_or_pump_state = None diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index 77f85224913..b968d925675 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -125,6 +125,61 @@ 'state': '33', }) # --- +# name: test_all_entities[sensor.test_model_central_heating_pump_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_central_heating_pump_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Central heating pump flow', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'central_heating_flow_volume', + 'unique_id': '0000-1111-2222-3333_central_heating_flow_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_central_heating_pump_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Test Model Central heating pump flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_central_heating_pump_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- # name: test_all_entities[sensor.test_model_compressor_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -390,6 +445,61 @@ 'state': '88', }) # --- +# name: test_all_entities[sensor.test_model_dhw_pump_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_dhw_pump_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW pump flow', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_flow_volume', + 'unique_id': '0000-1111-2222-3333_dhw_flow_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_pump_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Test Model DHW pump flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_dhw_pump_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.12', + }) +# --- # name: test_all_entities[sensor.test_model_dhw_top_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index f3eec282704..eab571b09ed 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -33,7 +33,7 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 15), (True, 17)]) +@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 16), (True, 19)]) async def test_create_entities( hass: HomeAssistant, mock_weheat_discover: AsyncMock, From 9b274a0bc43a74dd8ba901925a0c920c7fe498e5 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:40:29 -0400 Subject: [PATCH 099/116] Correct template fan optimistic mode and supported features (#142414) --- homeassistant/components/template/fan.py | 72 ++-- tests/components/template/test_fan.py | 471 +++++++++++++++++------ 2 files changed, 398 insertions(+), 145 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index f3bc26391a9..7ec62891784 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -149,17 +149,21 @@ class TemplateFan(TemplateEntity, FanEntity): self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE) self._direction_template = config.get(CONF_DIRECTION_TEMPLATE) - for action_id in ( - CONF_ON_ACTION, - CONF_OFF_ACTION, - CONF_SET_PERCENTAGE_ACTION, - CONF_SET_PRESET_MODE_ACTION, - CONF_SET_OSCILLATING_ACTION, - CONF_SET_DIRECTION_ACTION, + self._attr_supported_features |= ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + ) + for action_id, supported_feature in ( + (CONF_ON_ACTION, 0), + (CONF_OFF_ACTION, 0), + (CONF_SET_PERCENTAGE_ACTION, FanEntityFeature.SET_SPEED), + (CONF_SET_PRESET_MODE_ACTION, FanEntityFeature.PRESET_MODE), + (CONF_SET_OSCILLATING_ACTION, FanEntityFeature.OSCILLATE), + (CONF_SET_DIRECTION_ACTION, FanEntityFeature.DIRECTION), ): # Scripts can be an empty list, therefore we need to check for None if (action_config := config.get(action_id)) is not None: self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state: bool | None = False self._percentage: int | None = None @@ -172,19 +176,6 @@ class TemplateFan(TemplateEntity, FanEntity): # List of valid preset modes self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) - - if self._percentage_template: - self._attr_supported_features |= FanEntityFeature.SET_SPEED - if self._preset_mode_template and self._preset_modes: - self._attr_supported_features |= FanEntityFeature.PRESET_MODE - if self._oscillating_template: - self._attr_supported_features |= FanEntityFeature.OSCILLATE - if self._direction_template: - self._attr_supported_features |= FanEntityFeature.DIRECTION - self._attr_supported_features |= ( - FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - ) - self._attr_assumed_state = self._template is None @property @@ -270,6 +261,8 @@ class TemplateFan(TemplateEntity, FanEntity): if self._template is None: self._state = percentage != 0 + + if self._template is None or self._percentage_template is None: self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -285,32 +278,39 @@ class TemplateFan(TemplateEntity, FanEntity): if self._template is None: self._state = True + + if self._template is None or self._preset_mode_template is None: self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation of the fan.""" - if (script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION)) is None: - return - self._oscillating = oscillating - await self.async_run_script( - script, - run_variables={ATTR_OSCILLATING: self.oscillating}, - context=self._context, - ) + if ( + script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION) + ) is not None: + await self.async_run_script( + script, + run_variables={ATTR_OSCILLATING: self.oscillating}, + context=self._context, + ) + + if self._oscillating_template is None: + self.async_write_ha_state() async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - if (script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION)) is None: - return - if direction in _VALID_DIRECTIONS: self._direction = direction - await self.async_run_script( - script, - run_variables={ATTR_DIRECTION: direction}, - context=self._context, - ) + if ( + script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION) + ) is not None: + await self.async_run_script( + script, + run_variables={ATTR_DIRECTION: direction}, + context=self._context, + ) + if self._direction_template is None: + self.async_write_ha_state() else: _LOGGER.error( "Received invalid direction: %s for entity %s. Expected: %s", diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index e92bc82f5ae..dac97931fa7 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -1,9 +1,12 @@ """The tests for the Template fan platform.""" +from typing import Any + import pytest import voluptuous as vol from homeassistant import setup +from homeassistant.components import fan from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, @@ -17,11 +20,15 @@ from homeassistant.components.fan import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component + +from .conftest import ConfigurationStyle from tests.common import assert_setup_component from tests.components.fan import common -_TEST_FAN = "fan.test_fan" +_TEST_OBJECT_ID = "test_fan" +_TEST_FAN = f"fan.{_TEST_OBJECT_ID}" # Represent for fan's state _STATE_INPUT_BOOLEAN = "input_boolean.state" # Represent for fan's state @@ -36,6 +43,169 @@ _OSC_INPUT = "input_select.osc" _DIRECTION_INPUT_SELECT = "input_select.direction" +OPTIMISTIC_ON_OFF_CONFIG = { + "turn_on": { + "service": "test.automation", + "data": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + }, + "turn_off": { + "service": "test.automation", + "data": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + }, +} + + +PERCENTAGE_ACTION = { + "set_percentage": { + "action": "test.automation", + "data": { + "action": "set_percentage", + "percentage": "{{ percentage }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_PERCENTAGE_CONFIG = { + **OPTIMISTIC_ON_OFF_CONFIG, + **PERCENTAGE_ACTION, +} + +PRESET_MODE_ACTION = { + "set_preset_mode": { + "action": "test.automation", + "data": { + "action": "set_preset_mode", + "preset_mode": "{{ preset_mode }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_PRESET_MODE_CONFIG = { + **OPTIMISTIC_ON_OFF_CONFIG, + **PRESET_MODE_ACTION, +} +OPTIMISTIC_PRESET_MODE_CONFIG2 = { + **OPTIMISTIC_PRESET_MODE_CONFIG, + "preset_modes": ["auto", "low", "medium", "high"], +} + +OSCILLATE_ACTION = { + "set_oscillating": { + "action": "test.automation", + "data": { + "action": "set_oscillating", + "oscillating": "{{ oscillating }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_OSCILLATE_CONFIG = { + **OPTIMISTIC_ON_OFF_CONFIG, + **OSCILLATE_ACTION, +} + +DIRECTION_ACTION = { + "set_direction": { + "action": "test.automation", + "data": { + "action": "set_direction", + "direction": "{{ direction }}", + "caller": "{{ this.entity_id }}", + }, + }, +} +OPTIMISTIC_DIRECTION_CONFIG = { + **OPTIMISTIC_ON_OFF_CONFIG, + **DIRECTION_ACTION, +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, light_config: dict[str, Any] +) -> None: + """Do setup of fan integration via legacy format.""" + config = {"fan": {"platform": "template", "fans": light_config}} + + with assert_setup_component(count, fan.DOMAIN): + assert await async_setup_component( + hass, + fan.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_legacy_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy fan that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_legacy_format( + hass, + count, + { + _TEST_OBJECT_ID: { + **extra_config, + "value_template": "{{ 1 == 1 }}", + **extra, + } + }, + ) + + +@pytest.fixture +async def setup_fan( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + light_config: dict[str, Any], +) -> None: + """Do setup of fan integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, light_config) + + +@pytest.fixture +async def setup_test_fan_with_extra_config( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + fan_config: dict[str, Any], + extra_config: dict[str, Any], +) -> None: + """Do setup of fan integration.""" + config = {_TEST_OBJECT_ID: {**fan_config, **extra_config}} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, config) + + +@pytest.fixture +async def setup_optimistic_fan_attribute( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + extra_config: dict, +) -> None: + """Do setup of a non-optimistic fan with an optimistic attribute.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, "", "", extra_config + ) + + @pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", @@ -123,28 +293,21 @@ async def test_wrong_template_config(hass: HomeAssistant) -> None: "platform": "template", "fans": { "test_fan": { - "value_template": """ - {% if is_state('input_boolean.state', 'True') %} - {{ 'on' }} - {% else %} - {{ 'off' }} - {% endif %} - """, + "value_template": "{{ is_state('input_boolean.state', 'True') }}", "percentage_template": ( "{{ states('input_number.percentage') }}" ), + **OPTIMISTIC_ON_OFF_CONFIG, + **PERCENTAGE_ACTION, "preset_mode_template": ( "{{ states('input_select.preset_mode') }}" ), + **PRESET_MODE_ACTION, "oscillating_template": "{{ states('input_select.osc') }}", + **OSCILLATE_ACTION, "direction_template": "{{ states('input_select.direction') }}", + **DIRECTION_ACTION, "speed_count": "3", - "set_percentage": { - "service": "script.fans_set_speed", - "data_template": {"percentage": "{{ percentage }}"}, - }, - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, } }, } @@ -188,8 +351,7 @@ async def test_templates_with_entities(hass: HomeAssistant) -> None: "test_fan": { "value_template": "{{ 'on' }}", "percentage_template": "{{ states('sensor.percentage') }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PERCENTAGE_CONFIG, }, }, } @@ -215,8 +377,7 @@ async def test_templates_with_entities(hass: HomeAssistant) -> None: "preset_mode_template": ( "{{ states('sensor.preset_mode') }}" ), - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PRESET_MODE_CONFIG, }, }, } @@ -284,8 +445,7 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "fans": { "test_fan": { "value_template": "{{ 'unavailable' }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_ON_OFF_CONFIG, } }, } @@ -299,11 +459,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "fans": { "test_fan": { "value_template": "{{ 'on' }}", - "oscillating_template": "{{ 'unavailable' }}", - "direction_template": "{{ 'unavailable' }}", "percentage_template": "{{ 0 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 'unavailable' }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'unavailable' }}", + **DIRECTION_ACTION, } }, } @@ -317,11 +478,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "fans": { "test_fan": { "value_template": "{{ 'on' }}", - "oscillating_template": "{{ 1 == 1 }}", - "direction_template": "{{ 'forward' }}", "percentage_template": "{{ 66 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 1 == 1 }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'forward' }}", + **DIRECTION_ACTION, } }, } @@ -335,11 +497,12 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: "fans": { "test_fan": { "value_template": "{{ 'abc' }}", - "oscillating_template": "{{ 'xyz' }}", - "direction_template": "{{ 'right' }}", "percentage_template": "{{ 0 }}", - "turn_on": {"service": "script.fan_on"}, - "turn_off": {"service": "script.fan_off"}, + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating_template": "{{ 'xyz' }}", + **OSCILLATE_ACTION, + "direction_template": "{{ 'right' }}", + **DIRECTION_ACTION, } }, } @@ -541,77 +704,18 @@ async def test_increase_decrease_speed( _verify(hass, state, value, None, None, None) -async def test_no_value_template(hass: HomeAssistant, calls: list[ServiceCall]) -> None: +async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test a fan without a value_template.""" await _register_fan_sources(hass) with assert_setup_component(1, "fan"): test_fan_config = { - "preset_mode_template": "{{ states('input_select.preset_mode') }}", + **OPTIMISTIC_ON_OFF_CONFIG, "preset_modes": ["auto"], - "percentage_template": "{{ states('input_number.percentage') }}", - "oscillating_template": "{{ states('input_select.osc') }}", - "direction_template": "{{ states('input_select.direction') }}", - "turn_on": [ - { - "service": "input_boolean.turn_on", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "turn_off": [ - { - "service": "input_boolean.turn_off", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - { - "service": "test.automation", - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - }, - ], - "set_preset_mode": [ - { - "service": "input_select.select_option", - "data_template": { - "entity_id": _PRESET_MODE_INPUT_SELECT, - "option": "{{ preset_mode }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_preset_mode", - "caller": "{{ this.entity_id }}", - "option": "{{ preset_mode }}", - }, - }, - ], - "set_percentage": [ - { - "service": "input_number.set_value", - "data_template": { - "entity_id": _PERCENTAGE_INPUT_NUMBER, - "value": "{{ percentage }}", - }, - }, - { - "service": "test.automation", - "data_template": { - "action": "set_value", - "caller": "{{ this.entity_id }}", - "value": "{{ percentage }}", - }, - }, - ], + **PRESET_MODE_ACTION, + **PERCENTAGE_ACTION, + **OSCILLATE_ACTION, + **DIRECTION_ACTION, } assert await setup.async_setup_component( hass, @@ -624,32 +728,127 @@ async def test_no_value_template(hass: HomeAssistant, calls: list[ServiceCall]) await hass.async_block_till_done() await common.async_turn_on(hass, _TEST_FAN) - _verify(hass, STATE_ON, 0, None, None, "auto") + _verify(hass, STATE_ON) + + assert len(calls) == 1 + assert calls[-1].data["action"] == "turn_on" + assert calls[-1].data["caller"] == _TEST_FAN await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, 0, None, None, "auto") + _verify(hass, STATE_OFF) + + assert len(calls) == 2 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == _TEST_FAN percent = 100 await common.async_set_percentage(hass, _TEST_FAN, percent) - assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent - _verify(hass, STATE_ON, percent, None, None, "auto") + _verify(hass, STATE_ON, percent) + + assert len(calls) == 3 + assert calls[-1].data["action"] == "set_percentage" + assert calls[-1].data["percentage"] == 100 + assert calls[-1].data["caller"] == _TEST_FAN await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, percent, None, None, "auto") + _verify(hass, STATE_OFF, percent) + + assert len(calls) == 4 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == _TEST_FAN preset = "auto" await common.async_set_preset_mode(hass, _TEST_FAN, preset) assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == preset _verify(hass, STATE_ON, percent, None, None, preset) + assert len(calls) == 5 + assert calls[-1].data["action"] == "set_preset_mode" + assert calls[-1].data["preset_mode"] == preset + assert calls[-1].data["caller"] == _TEST_FAN + await common.async_turn_off(hass, _TEST_FAN) _verify(hass, STATE_OFF, percent, None, None, preset) - await common.async_set_direction(hass, _TEST_FAN, True) - _verify(hass, STATE_OFF, percent, None, None, preset) + assert len(calls) == 6 + assert calls[-1].data["action"] == "turn_off" + assert calls[-1].data["caller"] == _TEST_FAN + + await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD) + _verify(hass, STATE_OFF, percent, None, DIRECTION_FORWARD, preset) + + assert len(calls) == 7 + assert calls[-1].data["action"] == "set_direction" + assert calls[-1].data["direction"] == DIRECTION_FORWARD + assert calls[-1].data["caller"] == _TEST_FAN await common.async_oscillate(hass, _TEST_FAN, True) - _verify(hass, STATE_OFF, percent, None, None, preset) + _verify(hass, STATE_OFF, percent, True, DIRECTION_FORWARD, preset) + + assert len(calls) == 8 + assert calls[-1].data["action"] == "set_oscillating" + assert calls[-1].data["oscillating"] is True + assert calls[-1].data["caller"] == _TEST_FAN + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize("style", [ConfigurationStyle.LEGACY]) +@pytest.mark.parametrize( + ("extra_config", "attribute", "action", "verify_attr", "coro", "value"), + [ + ( + OPTIMISTIC_PERCENTAGE_CONFIG, + "percentage", + "set_percentage", + "expected_percentage", + common.async_set_percentage, + 50, + ), + ( + OPTIMISTIC_PRESET_MODE_CONFIG2, + "preset_mode", + "set_preset_mode", + "expected_preset_mode", + common.async_set_preset_mode, + "auto", + ), + ( + OPTIMISTIC_OSCILLATE_CONFIG, + "oscillating", + "set_oscillating", + "expected_oscillating", + common.async_oscillate, + True, + ), + ( + OPTIMISTIC_DIRECTION_CONFIG, + "direction", + "set_direction", + "expected_direction", + common.async_set_direction, + DIRECTION_FORWARD, + ), + ], +) +async def test_optimistic_attributes( + hass: HomeAssistant, + attribute: str, + action: str, + verify_attr: str, + coro, + value: Any, + setup_optimistic_fan_attribute, + calls: list[ServiceCall], +) -> None: + """Test setting percentage with optimistic template.""" + + await coro(hass, _TEST_FAN, value) + _verify(hass, STATE_ON, **{verify_attr: value}) + + assert len(calls) == 1 + assert calls[-1].data["action"] == action + assert calls[-1].data[attribute] == value + assert calls[-1].data["caller"] == _TEST_FAN async def test_increase_decrease_speed_default_speed_count( @@ -702,10 +901,10 @@ async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> def _verify( hass: HomeAssistant, expected_state: str, - expected_percentage: int | None, - expected_oscillating: bool | None, - expected_direction: str | None, - expected_preset_mode: str | None, + expected_percentage: int | None = None, + expected_oscillating: bool | None = None, + expected_direction: str | None = None, + expected_preset_mode: str | None = None, ) -> None: """Verify fan's state, speed and osc.""" state = hass.states.get(_TEST_FAN) @@ -1093,3 +1292,57 @@ async def test_implemented_preset_mode(hass: HomeAssistant) -> None: attributes = state.attributes assert attributes.get("percentage") is None assert attributes.get("supported_features") & FanEntityFeature.PRESET_MODE + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "fan_config"), + [ + ( + ConfigurationStyle.LEGACY, + { + "turn_on": [], + "turn_off": [], + }, + ), + ], +) +@pytest.mark.parametrize( + ("extra_config", "supported_features"), + [ + ( + { + "set_percentage": [], + }, + FanEntityFeature.SET_SPEED, + ), + ( + { + "set_preset_mode": [], + }, + FanEntityFeature.PRESET_MODE, + ), + ( + { + "set_oscillating": [], + }, + FanEntityFeature.OSCILLATE, + ), + ( + { + "set_direction": [], + }, + FanEntityFeature.DIRECTION, + ), + ], +) +async def test_empty_action_config( + hass: HomeAssistant, + supported_features: FanEntityFeature, + setup_test_fan_with_extra_config, +) -> None: + """Test configuration with empty script.""" + state = hass.states.get(_TEST_FAN) + assert state.attributes["supported_features"] == ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON | supported_features + ) From efc44d83bb3de2f6101ffbfd60b149b3686aea0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herv=C3=A9=20Cauwelier?= Date: Mon, 14 Apr 2025 15:41:10 +0200 Subject: [PATCH 100/116] =?UTF-8?q?Add=20wind=20gust=20attribute=20to=20M?= =?UTF-8?q?=C3=A9t=C3=A9o=20France=20weather=20entity=20(#136839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/meteo_france/weather.py | 5 +++++ tests/components/meteo_france/snapshots/test_weather.ambr | 1 + 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 67a56271c2b..e2df35f21f3 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -161,6 +161,11 @@ class MeteoFranceWeather( """Return the wind speed.""" return self.coordinator.data.current_forecast["wind"]["speed"] + @property + def native_wind_gust_speed(self): + """Return the wind gust speed.""" + return self.coordinator.data.current_forecast["wind"].get("gust") + @property def wind_bearing(self): """Return the wind bearing.""" diff --git a/tests/components/meteo_france/snapshots/test_weather.ambr b/tests/components/meteo_france/snapshots/test_weather.ambr index 7c64ee86671..d5e03c95de2 100644 --- a/tests/components/meteo_france/snapshots/test_weather.ambr +++ b/tests/components/meteo_france/snapshots/test_weather.ambr @@ -47,6 +47,7 @@ 'temperature_unit': , 'visibility_unit': , 'wind_bearing': 200, + 'wind_gust_speed': 64.8, 'wind_speed': 28.8, 'wind_speed_unit': , }), From 0a424f53b11a9cfd8f28961efcbea8aff3a153a7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 15:52:31 +0200 Subject: [PATCH 101/116] Add common states for "Auto" and "Manual" (#142914) --- homeassistant/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 14190ba008d..43b9b1fdb3f 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -118,6 +118,7 @@ }, "state": { "active": "Active", + "auto": "Auto", "charging": "Charging", "closed": "Closed", "closing": "Closing", @@ -131,6 +132,7 @@ "idle": "Idle", "locked": "Locked", "low": "Low", + "manual": "Manual", "medium": "Medium", "no": "No", "normal": "Normal", From 9e93d1fd7e595d8a9095ce52e179c4999ca14f8e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Apr 2025 17:17:06 +0200 Subject: [PATCH 102/116] Introduce common base entity for Comelit bridge (#142855) --- .../components/comelit/alarm_control_panel.py | 19 ++++++++---- .../components/comelit/binary_sensor.py | 1 - homeassistant/components/comelit/climate.py | 13 ++------- homeassistant/components/comelit/cover.py | 17 +++-------- homeassistant/components/comelit/entity.py | 29 +++++++++++++++++++ .../components/comelit/humidifier.py | 12 ++------ homeassistant/components/comelit/light.py | 21 ++------------ .../components/comelit/manifest.json | 1 + .../components/comelit/quality_scale.yaml | 4 +-- homeassistant/components/comelit/sensor.py | 13 ++------- homeassistant/components/comelit/switch.py | 12 ++------ script/hassfest/quality_scale.py | 1 - 12 files changed, 62 insertions(+), 81 deletions(-) create mode 100644 homeassistant/components/comelit/entity.py diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 1ad26905dd1..53e767b4434 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -83,7 +83,6 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel config_entry_entry_id: str, ) -> None: """Initialize the alarm panel.""" - self._api = coordinator.api self._area_index = area.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id @@ -137,30 +136,38 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if code != str(self._api.device_pin): + if code != str(self.coordinator.api.device_pin): return - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[DISABLE] + ) await self._async_update_state( AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE] ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[AWAY] + ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY] ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[HOME] + ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1] ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT]) + await self.coordinator.api.set_zone_status( + self._area.index, ALARM_ACTIONS[NIGHT] + ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT] ) diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index dfa6d3e97f3..e1be330afae 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -50,7 +50,6 @@ class ComelitVedoBinarySensorEntity( config_entry_entry_id: str, ) -> None: """Init sensor entity.""" - self._api = coordinator.api self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 3ec79001d55..be5b892e53c 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -19,10 +19,10 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -89,7 +89,7 @@ async def async_setup_entry( ) -class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity): +class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity): """Climate device.""" _attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] @@ -102,7 +102,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True _attr_name = None def __init__( @@ -112,13 +111,7 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity config_entry_entry_id: str, ) -> None: """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) self._update_attributes() def _update_attributes(self) -> None: diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index befcb0c35d4..d430952fabf 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -11,9 +11,9 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -34,13 +34,10 @@ async def async_setup_entry( ) -class ComelitCoverEntity( - CoordinatorEntity[ComelitSerialBridge], RestoreEntity, CoverEntity -): +class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): """Cover device.""" _attr_device_class = CoverDeviceClass.SHUTTER - _attr_has_entity_name = True _attr_name = None def __init__( @@ -50,13 +47,7 @@ class ComelitCoverEntity( config_entry_entry_id: str, ) -> None: """Init cover entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) # Device doesn't provide a status so we assume UNKNOWN at first startup self._last_action: int | None = None self._last_state: str | None = None @@ -101,7 +92,7 @@ class ComelitCoverEntity( async def _cover_set_state(self, action: int, state: int) -> None: """Set desired cover state.""" self._last_state = self.state - await self._api.set_device_status(COVER, self._device.index, action) + await self.coordinator.api.set_device_status(COVER, self._device.index, action) self.coordinator.data[COVER][self._device.index].status = state self.async_write_ha_state() diff --git a/homeassistant/components/comelit/entity.py b/homeassistant/components/comelit/entity.py new file mode 100644 index 00000000000..409cd6a3f42 --- /dev/null +++ b/homeassistant/components/comelit/entity.py @@ -0,0 +1,29 @@ +"""Base entity for Comelit.""" + +from __future__ import annotations + +from aiocomelit import ComelitSerialBridgeObject + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ComelitSerialBridge + + +class ComelitBridgeBaseEntity(CoordinatorEntity[ComelitSerialBridge]): + """Comelit Bridge base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init cover entity.""" + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device, device.type) diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index d7b20f731a9..816d5c6bb38 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -19,10 +19,10 @@ from homeassistant.components.humidifier import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -92,14 +92,13 @@ async def async_setup_entry( async_add_entities(entities) -class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], HumidifierEntity): +class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity): """Humidifier device.""" _attr_supported_features = HumidifierEntityFeature.MODES _attr_available_modes = [MODE_NORMAL, MODE_AUTO] _attr_min_humidity = 10 _attr_max_humidity = 90 - _attr_has_entity_name = True def __init__( self, @@ -112,13 +111,8 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier device_class: HumidifierDeviceClass, ) -> None: """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available + super().__init__(coordinator, device, config_entry_entry_id) self._attr_unique_id = f"{config_entry_entry_id}-{device.index}-{device_class}" - self._attr_device_info = coordinator.platform_device_info(device, device_class) self._attr_device_class = device_class self._attr_translation_key = device_class.value self._active_mode = active_mode diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 53cf6bdcb46..27d9a8d57dd 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -4,15 +4,14 @@ from __future__ import annotations from typing import Any, cast -from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -33,29 +32,13 @@ async def async_setup_entry( ) -class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): +class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): """Light device.""" _attr_color_mode = ColorMode.ONOFF - _attr_has_entity_name = True _attr_name = None _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__( - self, - coordinator: ComelitSerialBridge, - device: ComelitSerialBridgeObject, - config_entry_entry_id: str, - ) -> None: - """Init light entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) - async def _light_set_state(self, state: int) -> None: """Set desired light state.""" await self.coordinator.api.set_device_status(LIGHT, self._device.index, state) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 3abfc222e7d..303773ebc7d 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], + "quality_scale": "bronze", "requirements": ["aiocomelit==0.11.3"] } diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml index 9c4aab049d1..56922f175b9 100644 --- a/homeassistant/components/comelit/quality_scale.yaml +++ b/homeassistant/components/comelit/quality_scale.yaml @@ -5,9 +5,7 @@ rules: comment: no actions appropriate-polling: done brands: done - common-modules: - status: todo - comment: PR in progress + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index c93ccd30eb6..a11cac4e1c0 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -19,6 +19,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -95,10 +96,9 @@ async def async_setup_vedo_entry( async_add_entities(entities) -class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): +class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): """Sensor device.""" - _attr_has_entity_name = True _attr_name = None def __init__( @@ -109,13 +109,7 @@ class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEn description: SensorEntityDescription, ) -> None: """Init sensor entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) + super().__init__(coordinator, device, config_entry_entry_id) self.entity_description = description @@ -144,7 +138,6 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity description: SensorEntityDescription, ) -> None: """Init sensor entity.""" - self._api = coordinator.api self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 2c751cbe2cb..9c9f6b747d4 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -10,9 +10,9 @@ from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +from .entity import ComelitBridgeBaseEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -39,10 +39,9 @@ async def async_setup_entry( async_add_entities(entities) -class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): +class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): """Switch device.""" - _attr_has_entity_name = True _attr_name = None def __init__( @@ -52,13 +51,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): config_entry_entry_id: str, ) -> None: """Init switch entity.""" - self._api = coordinator.api - self._device = device - super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id - # because no serial number or mac is available + super().__init__(coordinator, device, config_entry_entry_id) self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, device.type) if device.type == OTHER: self._attr_device_class = SwitchDeviceClass.OUTLET diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index f1ab244e30a..d564bb51ead 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1304,7 +1304,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "coinbase", "color_extractor", "comed_hourly_pricing", - "comelit", "comfoconnect", "command_line", "compensation", From 82efa0893ffa396b5fc58bd6384a0dc28bcb5ce7 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 14 Apr 2025 09:26:21 -0600 Subject: [PATCH 103/116] Vesync Display Switch Feature (#137493) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/strings.json | 5 + homeassistant/components/vesync/switch.py | 23 +- tests/components/vesync/common.py | 2 + .../vesync/snapshots/test_diagnostics.ambr | 24 ++ .../vesync/snapshots/test_switch.ambr | 315 ++++++++++++++++++ tests/components/vesync/test_init.py | 4 +- tests/components/vesync/test_switch.py | 78 ++++- 7 files changed, 443 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 9b63bf3e614..241ccfa0af0 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -65,6 +65,11 @@ "name": "Mist level" } }, + "switch": { + "display": { + "name": "Display" + } + }, "select": { "night_light_level": { "name": "Night light level", diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 3e8deedb4ad..06fbd3606bd 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -14,10 +14,11 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import is_outlet, is_wall_switch +from .common import is_outlet, is_wall_switch, rgetattr from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -45,6 +46,14 @@ SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( on_fn=lambda device: device.turn_on(), off_fn=lambda device: device.turn_off(), ), + VeSyncSwitchEntityDescription( + key="display", + is_on=lambda device: device.display_state, + exists_fn=lambda device: rgetattr(device, "display_state") is not None, + translation_key="display", + on_fn=lambda device: device.turn_on_display(), + off_fn=lambda device: device.turn_off_display(), + ), ) @@ -111,10 +120,14 @@ class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - if self.entity_description.off_fn(self.device): - self.schedule_update_ha_state() + if not self.entity_description.off_fn(self.device): + raise HomeAssistantError("An error occurred while turning off.") + + self.schedule_update_ha_state() def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - if self.entity_description.on_fn(self.device): - self.schedule_update_ha_state() + if not self.entity_description.on_fn(self.device): + raise HomeAssistantError("An error occurred while turning on.") + + self.schedule_update_ha_state() diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 18cb094563e..5795c977120 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -15,6 +15,8 @@ ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" +ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 407e18d65b6..aa55a9be3cb 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -290,6 +290,30 @@ }), 'unit_of_measurement': '%', }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fan_display', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Display', + }), + 'entity_id': 'switch.fan_display', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), ]), 'name': 'Fan', 'name_by_user': None, diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 1faed941338..f25aaf3d51b 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -36,8 +36,53 @@ # --- # name: test_switch_state[Air Purifier 131s][entities] list([ + 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.air_purifier_131s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'air-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 131s][switch.air_purifier_131s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 131s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_131s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 200s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -75,8 +120,53 @@ # --- # name: test_switch_state[Air Purifier 200s][entities] list([ + 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.air_purifier_200s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 200s][switch.air_purifier_200s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 200s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_200s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 400s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -114,8 +204,53 @@ # --- # name: test_switch_state[Air Purifier 400s][entities] list([ + 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.air_purifier_400s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '400s-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 400s][switch.air_purifier_400s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 400s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_400s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Air Purifier 600s][devices] list([ DeviceRegistryEntrySnapshot({ @@ -153,8 +288,53 @@ # --- # name: test_switch_state[Air Purifier 600s][entities] list([ + 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.air_purifier_600s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '600s-purifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Air Purifier 600s][switch.air_purifier_600s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 600s Display', + }), + 'context': , + 'entity_id': 'switch.air_purifier_600s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Dimmable Light][devices] list([ DeviceRegistryEntrySnapshot({ @@ -270,8 +450,53 @@ # --- # name: test_switch_state[Humidifier 200s][entities] list([ + 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.humidifier_200s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '200s-humidifier4321-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Humidifier 200s][switch.humidifier_200s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Humidifier 200s Display', + }), + 'context': , + 'entity_id': 'switch.humidifier_200s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Humidifier 600S][devices] list([ DeviceRegistryEntrySnapshot({ @@ -309,8 +534,53 @@ # --- # name: test_switch_state[Humidifier 600S][entities] list([ + 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.humidifier_600s_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': '600s-humidifier-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Humidifier 600S][switch.humidifier_600s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Humidifier 600S Display', + }), + 'context': , + 'entity_id': 'switch.humidifier_600s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Outlet][devices] list([ DeviceRegistryEntrySnapshot({ @@ -433,8 +703,53 @@ # --- # name: test_switch_state[SmartTowerFan][entities] list([ + 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.smarttowerfan_display', + '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': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'smarttowerfan-display', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[SmartTowerFan][switch.smarttowerfan_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SmartTowerFan Display', + }), + 'context': , + 'entity_id': 'switch.smarttowerfan_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 31df2418b3d..d1e76174ea0 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -163,11 +163,11 @@ async def test_migrate_config_entry( assert migrated_humidifer is not None assert migrated_humidifer.unique_id == "humidifer" - # Assert that only one entity exists in the switch domain + # Assert that entity exists in the switch domain switch_entities = [ e for e in entity_registry.entities.values() if e.domain == "switch" ] - assert len(switch_entities) == 1 + assert len(switch_entities) == 2 humidifer_entities = [ e for e in entity_registry.entities.values() if e.domain == "humidifer" diff --git a/tests/components/vesync/test_switch.py b/tests/components/vesync/test_switch.py index 111f2b80960..e5d5986b364 100644 --- a/tests/components/vesync/test_switch.py +++ b/tests/components/vesync/test_switch.py @@ -1,17 +1,24 @@ """Tests for the switch module.""" +from contextlib import nullcontext +from unittest.mock import patch + import pytest import requests_mock from syrupy import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_SWITCH_DISPLAY, mock_devices_response from tests.common import MockConfigEntry +NoException = nullcontext() + @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) async def test_switch_state( @@ -49,3 +56,72 @@ async def test_switch_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ], +) +async def test_turn_on_off_display_success( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test switch turn on and off command with success response.""" + + with ( + patch( + command, + return_value=True, + ) as method_mock, + patch( + "homeassistant.components.vesync.switch.VeSyncSwitchEntity.schedule_update_ha_state" + ) as update_mock, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_SWITCH_DISPLAY}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ], +) +async def test_turn_on_off_display_raises_error( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test switch turn on and off command raises HomeAssistantError.""" + + with ( + patch( + command, + return_value=False, + ) as method_mock, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_SWITCH_DISPLAY}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() From 23844c0f1adbc440e96fab3770cfd84e282c7f38 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 18:41:20 +0200 Subject: [PATCH 104/116] Use common state for "Auto", fix sentence-casing in `demo` (#142934) --- homeassistant/components/demo/strings.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index d40d3f56a6a..e22b4c413d5 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -28,10 +28,10 @@ "state_attributes": { "fan_mode": { "state": { - "auto_high": "Auto High", - "auto_low": "Auto Low", - "on_high": "On High", - "on_low": "On Low" + "auto_high": "Auto high", + "auto_low": "Auto low", + "on_high": "On high", + "on_low": "On low" } }, "swing_mode": { @@ -39,14 +39,14 @@ "1": "1", "2": "2", "3": "3", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "off": "[%key:common::state::off%]" } }, "swing_horizontal_mode": { "state": { "rangefull": "Full range", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "off": "[%key:common::state::off%]" } } @@ -58,7 +58,7 @@ "state_attributes": { "preset_mode": { "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", "smart": "Smart", "on": "[%key:common::state::on%]" @@ -92,9 +92,9 @@ "select": { "speed": { "state": { - "light_speed": "Light Speed", - "ludicrous_speed": "Ludicrous Speed", - "ridiculous_speed": "Ridiculous Speed" + "light_speed": "Light speed", + "ludicrous_speed": "Ludicrous speed", + "ridiculous_speed": "Ridiculous speed" } } }, @@ -113,7 +113,7 @@ "model_s": { "state_attributes": { "cleaned_area": { - "name": "Cleaned Area" + "name": "Cleaned area" } } } From 49a9923b5c1f6dee749c4351ee417e810e505778 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 18:50:20 +0200 Subject: [PATCH 105/116] Use common state for "Auto" in `humidifier` (#142937) --- 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 361636eadc6..6c0c691c705 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -65,7 +65,7 @@ "normal": "[%key:common::state::normal%]", "home": "[%key:common::state::home%]", "away": "[%key:common::state::not_home%]", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "baby": "Baby", "boost": "Boost", "comfort": "Comfort", From 1463f05d46b5222863bbf89a19c419e8d1261830 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Apr 2025 18:56:54 +0200 Subject: [PATCH 106/116] Restore python 3.13.2 requirement (#142932) --- homeassistant/const.py | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index db0af10fba3..a7ace52a0da 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -28,8 +28,8 @@ MINOR_VERSION: Final = 5 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) # Truthy date string triggers showing related deprecation warning messages. REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" diff --git a/pyproject.toml b/pyproject.toml index 87d0cda9f71..6d28c0b9deb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] -requires-python = ">=3.13.0" +requires-python = ">=3.13.2" dependencies = [ "aiodns==3.2.0", # Integrations may depend on hassio integration without listing it to From 9ce44845fe8bfc28812266395c0f4b71ada1f51b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Apr 2025 07:10:05 -1000 Subject: [PATCH 107/116] Add a repair for ESPHome device conflicts (#142507) --- homeassistant/components/esphome/__init__.py | 6 +- homeassistant/components/esphome/manager.py | 67 ++++- homeassistant/components/esphome/repairs.py | 88 ++++++- homeassistant/components/esphome/strings.json | 23 ++ tests/components/esphome/test_manager.py | 78 ++++++ tests/components/esphome/test_repairs.py | 247 +++++++++++++++++- 6 files changed, 505 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 1e1a2763b59..f099d1284c0 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN @@ -23,7 +24,7 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView -from .manager import ESPHomeManager, cleanup_instance +from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -89,4 +90,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> """Remove an esphome config entry.""" if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS): async_remove_scanner(hass, bluetooth_mac_address.upper()) + async_delete_issue( + hass, DOMAIN, DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index e119d152d09..5721478c921 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -48,6 +48,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, + entity_registry as er, template, ) from homeassistant.helpers.device_registry import format_mac @@ -80,6 +81,8 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" + if TYPE_CHECKING: from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] SubscribeLogsResponse, @@ -418,7 +421,7 @@ class ESPHomeManager: assert reconnect_logic is not None, "Reconnect logic must be set" hass = self.hass cli = self.cli - stored_device_name = entry.data.get(CONF_DEVICE_NAME) + stored_device_name: str | None = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id if entry.options.get(CONF_SUBSCRIBE_LOGS): self._async_subscribe_logs(self._async_get_equivalent_log_level()) @@ -448,12 +451,36 @@ class ESPHomeManager: if not mac_address_matches and not unique_id_is_mac_address: hass.config_entries.async_update_entry(entry, unique_id=device_mac) + issue = DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) if not mac_address_matches and unique_id_is_mac_address: # If the unique id is a mac address # and does not match we have the wrong device and we need # to abort the connection. This can happen if the DHCP # server changes the IP address of the device and we end up # connecting to the wrong device. + if stored_device_name == device_info.name: + # If the device name matches it might be a device replacement + # or they made a mistake and flashed the same firmware on + # multiple devices. In this case we start a repair flow + # to ask them if its a mistake, or if they want to migrate + # the config entry to the replacement hardware. + shared_data = { + "name": device_info.name, + "mac": format_mac(device_mac), + "stored_mac": format_mac(unique_id), + "model": device_info.model, + "ip": self.host, + } + async_create_issue( + hass, + DOMAIN, + issue, + is_fixable=True, + severity=IssueSeverity.ERROR, + translation_key="device_conflict", + translation_placeholders=shared_data, + data={**shared_data, "entry_id": entry.entry_id}, + ) _LOGGER.error( "Unexpected device found at %s; " "expected `%s` with mac address `%s`, " @@ -475,6 +502,7 @@ class ESPHomeManager: # flow. return + async_delete_issue(hass, DOMAIN, issue) # Make sure we have the correct device name stored # so we can map the device to ESPHome Dashboard config # If we got here, we know the mac address matches or we @@ -902,3 +930,40 @@ async def cleanup_instance( await data.async_cleanup() await data.client.disconnect() return data + + +async def async_replace_device( + hass: HomeAssistant, + entry_id: str, + old_mac: str, # will be lower case (format_mac) + new_mac: str, # will be lower case (format_mac) +) -> None: + """Migrate an ESPHome entry to replace an existing device.""" + entry = hass.config_entries.async_get_entry(entry_id) + assert entry is not None + hass.config_entries.async_update_entry(entry, unique_id=new_mac) + + dev_reg = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): + dev_reg.async_update_device( + device.id, + new_connections={(dr.CONNECTION_NETWORK_MAC, new_mac)}, + ) + + ent_reg = er.async_get(hass) + upper_mac = new_mac.upper() + old_upper_mac = old_mac.upper() + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + # -- + old_unique_id = entity.unique_id.split("-") + new_unique_id = "-".join([upper_mac, *old_unique_id[1:]]) + if entity.unique_id != new_unique_id and entity.unique_id.startswith( + old_upper_mac + ): + ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + + domain_data = DomainData.get(hass) + store = domain_data.get_or_create_store(hass, entry) + if data := await store.async_load(): + data["device_info"]["mac_address"] = upper_mac + await store.async_save(data) diff --git a/homeassistant/components/esphome/repairs.py b/homeassistant/components/esphome/repairs.py index 31e4b88c689..42396fb8670 100644 --- a/homeassistant/components/esphome/repairs.py +++ b/homeassistant/components/esphome/repairs.py @@ -2,11 +2,95 @@ from __future__ import annotations +from typing import cast + +import voluptuous as vol + +from homeassistant import data_entry_flow from homeassistant.components.assist_pipeline.repair_flows import ( AssistInProgressDeprecatedRepairFlow, ) from homeassistant.components.repairs import RepairsFlow -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .manager import async_replace_device + + +class ESPHomeRepair(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str | int | float | None] | None) -> None: + """Initialize.""" + self._data = data + super().__init__() + + @callback + def _async_get_placeholders(self) -> dict[str, str]: + issue_registry = ir.async_get(self.hass) + issue = issue_registry.async_get_issue(self.handler, self.issue_id) + assert issue is not None + return issue.translation_placeholders or {} + + +class DeviceConflictRepair(ESPHomeRepair): + """Handler for an issue fixing device conflict.""" + + @property + def entry_id(self) -> str: + """Return the config entry id.""" + assert isinstance(self._data, dict) + return cast(str, self._data["entry_id"]) + + @property + def mac(self) -> str: + """Return the MAC address of the new device.""" + assert isinstance(self._data, dict) + return cast(str, self._data["mac"]) + + @property + def stored_mac(self) -> str: + """Return the MAC address of the stored device.""" + assert isinstance(self._data, dict) + return cast(str, self._data["stored_mac"]) + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["migrate", "manual"], + description_placeholders=self._async_get_placeholders(), + ) + + async def async_step_migrate( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the migrate step of a fix flow.""" + if user_input is None: + return self.async_show_form( + step_id="migrate", + data_schema=vol.Schema({}), + description_placeholders=self._async_get_placeholders(), + ) + entry_id = self.entry_id + await async_replace_device(self.hass, entry_id, self.stored_mac, self.mac) + self.hass.config_entries.async_schedule_reload(entry_id) + return self.async_create_entry(data={}) + + async def async_step_manual( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the manual step of a fix flow.""" + if user_input is None: + return self.async_show_form( + step_id="manual", + data_schema=vol.Schema({}), + description_placeholders=self._async_get_placeholders(), + ) + self.hass.config_entries.async_schedule_reload(self.entry_id) + return self.async_create_entry(data={}) async def async_create_fix_flow( @@ -17,6 +101,8 @@ async def async_create_fix_flow( """Create flow.""" if issue_id.startswith("assist_in_progress_deprecated"): return AssistInProgressDeprecatedRepairFlow(data) + if issue_id.startswith("device_conflict"): + return DeviceConflictRepair(data) # If ESPHome adds confirm-only repairs in the future, this should be changed # to return a ConfirmRepairFlow instead of raising a ValueError raise ValueError(f"unknown repair {issue_id}") diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 197ae46077d..8c20fb4e95a 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -130,6 +130,29 @@ "service_calls_not_allowed": { "title": "{name} is not permitted to perform Home Assistant actions", "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow." + }, + "device_conflict": { + "title": "Device conflict for {name}", + "fix_flow": { + "step": { + "init": { + "title": "Device conflict for {name}", + "description": "**The device `{name}` (`{model}`) at `{ip}` has reported a MAC address change from `{stored_mac}` to `{mac}`.**\n\nIf you have multiple devices with the same name, please rename or remove the one with MAC address `{mac}` to avoid conflicts.\n\nIf this is a hardware replacement, please confirm that you would like to migrate the Home Assistant configuration to the new device with MAC address `{mac}`.", + "menu_options": { + "migrate": "Migrate configuration to new device", + "manual": "Remove or rename device" + } + }, + "migrate": { + "title": "Confirm device replacement for {name}", + "description": "Are you sure you want to migrate the Home Assistant configuration for `{name}` (`{model}`) at `{ip}` from `{stored_mac}` to `{mac}`?" + }, + "manual": { + "title": "Remove or rename device {name}", + "description": "To resolve the conflict, either remove the device with MAC address `{mac}` from the network and restart the one with MAC address `{stored_mac}`, or re-flash the device with MAC address `{mac}` using a different name than `{name}`. Submit again once done." + } + } + } } } } diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 02a32190437..37ad7cb8f7f 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -33,6 +33,7 @@ from homeassistant.components.esphome.const import ( STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) +from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -688,6 +689,7 @@ async def test_connection_aborted_wrong_device( hass: HomeAssistant, mock_client: APIClient, caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test we abort the connection if the unique id is a mac and neither name or mac match.""" entry = MockConfigEntry( @@ -721,6 +723,82 @@ async def test_connection_aborted_wrong_device( "with mac address `11:22:33:44:55:aa`, found `different` " "with mac address `11:22:33:44:55:ab`" in caplog.text ) + # If its a different name, it means their DHCP + # reservations are missing and the device is not + # actually the same device, and there is nothing + # we can do to fix it so we only log a warning + assert not issue_registry.async_get_issue( + domain=DOMAIN, issue_id=DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) + + assert "Error getting setting up connection for" not in caplog.text + mock_client.disconnect = AsyncMock() + caplog.clear() + # Make sure discovery triggers a reconnect + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test", + macaddress="1122334455aa", + ) + new_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="test") + ) + mock_client.device_info = new_info + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == "192.168.43.184" + await hass.async_block_till_done() + assert len(new_info.mock_calls) == 2 + assert "Unexpected device found at" not in caplog.text + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_connection_aborted_wrong_device_same_name( + hass: HomeAssistant, + mock_client: APIClient, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we abort the connection if the unique id is a mac and the name matches.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455ab", name="test") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert ( + "Unexpected device found at 192.168.43.183; expected `test` " + "with mac address `11:22:33:44:55:aa`, found `test` " + "with mac address `11:22:33:44:55:ab`" in caplog.text + ) + # We should start a repair flow to help them fix the issue + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id=DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id) + ) assert "Error getting setting up connection for" not in caplog.text mock_client.disconnect = AsyncMock() diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index c365e65cbe1..5f6b75a3508 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -1,13 +1,258 @@ """Test ESPHome repairs.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +from aioesphomeapi import ( + APIClient, + BinarySensorInfo, + BinarySensorState, + DeviceInfo, + EntityInfo, + EntityState, + UserService, +) import pytest from homeassistant.components.esphome import repairs +from homeassistant.components.esphome.const import DOMAIN +from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT +from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) + +from .conftest import MockESPHomeDevice + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + get_repairs, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None: - """Test reate_fix_flow raises on unknown issue_id.""" + """Test create_fix_flow raises on unknown issue_id.""" with pytest.raises(ValueError): await repairs.async_create_fix_flow(hass, "no_such_issue", None) + + +async def test_device_conflict_manual( + hass: HomeAssistant, + mock_client: APIClient, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test guided manual conflict resolution.""" + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455ab", name="test", model="esp32-iso-poe" + ) + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert "Unexpected device found" in caplog.text + issue_id = DEVICE_CONFLICT_ISSUE_FORMAT.format(mock_config_entry.entry_id) + + issues = await get_repairs(hass, hass_ws_client) + assert issues + assert len(issues) == 1 + assert any(True for issue in issues if issue["issue_id"] == issue_id) + + await async_process_repairs_platforms(hass) + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "192.168.1.2", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.MENU + assert data["step_id"] == "init" + + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "manual"} + ) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "192.168.1.2", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.FORM + assert data["step_id"] == "manual" + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="11:22:33:44:55:aa", name="test", model="esp32-iso-poe" + ) + ) + caplog.clear() + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert "Unexpected device found" not in caplog.text + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_device_conflict_migration( + hass: HomeAssistant, + mock_client: APIClient, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test migrating existing configuration to new hardware.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + is_status_binary_sensor=True, + ) + ] + states = [BinarySensorState(key=1, state=None)] + user_service = [] + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + mock_config_entry = device.entry + + ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + assert ent_reg_entry + assert ent_reg_entry.unique_id == "11:22:33:44:55:AA-binary_sensor-mybinary_sensor" + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert entries is not None + for entry in entries: + assert entry.unique_id.startswith("11:22:33:44:55:AA-") + disconnect_done = hass.loop.create_future() + + async def async_disconnect(*args, **kwargs) -> None: + if not disconnect_done.done(): + disconnect_done.set_result(None) + + mock_client.disconnect = async_disconnect + new_device_info = DeviceInfo( + mac_address="11:22:33:44:55:AB", name="test", model="esp32-iso-poe" + ) + mock_client.device_info = AsyncMock(return_value=new_device_info) + device.device_info = new_device_info + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await disconnect_done + + assert "Unexpected device found" in caplog.text + issue_id = DEVICE_CONFLICT_ISSUE_FORMAT.format(mock_config_entry.entry_id) + + issues = await get_repairs(hass, hass_ws_client) + assert issues + assert len(issues) == 1 + assert any(True for issue in issues if issue["issue_id"] == issue_id) + + await async_process_repairs_platforms(hass) + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "test.local", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.MENU + assert data["step_id"] == "init" + + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "migrate"} + ) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "ip": "test.local", + "mac": "11:22:33:44:55:ab", + "model": "esp32-iso-poe", + "name": "test", + "stored_mac": "11:22:33:44:55:aa", + } + assert data["type"] == FlowResultType.FORM + assert data["step_id"] == "migrate" + + caplog.clear() + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert "Unexpected device found" not in caplog.text + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + assert mock_config_entry.unique_id == "11:22:33:44:55:ab" + ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + assert ent_reg_entry + assert ent_reg_entry.unique_id == "11:22:33:44:55:AB-binary_sensor-mybinary_sensor" + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert entries is not None + for entry in entries: + assert entry.unique_id.startswith("11:22:33:44:55:AB-") + + dev_entry = device_registry.async_get_device( + identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:ab")} + ) + assert dev_entry is not None + + old_dev_entry = device_registry.async_get_device( + identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:aa")} + ) + assert old_dev_entry is None From be6e1e5e15679aa6b7bc0016ff8c8cb9dc8ae199 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 19:29:22 +0200 Subject: [PATCH 108/116] Use common states "Auto"/"Manual", fix sentence-casing in `yamaha_musiccast` (#142931) --- .../components/yamaha_musiccast/strings.json | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index eaa5ac50c80..e38eb5955d9 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -29,29 +29,29 @@ "select": { "dimmer": { "state": { - "auto": "Auto" + "auto": "[%key:common::state::auto%]" } }, "zone_sleep": { "state": { "off": "[%key:common::state::off%]", - "30_min": "30 Minutes", - "60_min": "60 Minutes", - "90_min": "90 Minutes", - "120_min": "120 Minutes" + "30_min": "30 minutes", + "60_min": "60 minutes", + "90_min": "90 minutes", + "120_min": "120 minutes" } }, "zone_tone_control_mode": { "state": { - "manual": "Manual", - "auto": "Auto", + "manual": "[%key:common::state::manual%]", + "auto": "[%key:common::state::auto%]", "bypass": "Bypass" } }, "zone_surr_decoder_type": { "state": { "toggle": "[%key:common::action::toggle%]", - "auto": "Auto", + "auto": "[%key:common::state::auto%]", "dolby_pl": "Dolby ProLogic", "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", "dolby_pl2x_music": "Dolby ProLogic 2x Music", @@ -64,8 +64,8 @@ }, "zone_equalizer_mode": { "state": { - "manual": "Manual", - "auto": "Auto", + "manual": "[%key:common::state::manual%]", + "auto": "[%key:common::state::auto%]", "bypass": "[%key:component::yamaha_musiccast::entity::select::zone_tone_control_mode::state::bypass%]" } }, @@ -84,11 +84,11 @@ }, "zone_link_audio_delay": { "state": { - "audio_sync_on": "Audio Synchronization On", - "audio_sync_off": "Audio Synchronization Off", + "audio_sync_on": "Audio synchronization on", + "audio_sync_off": "Audio synchronization off", "balanced": "Balanced", - "lip_sync": "Lip Synchronization", - "audio_sync": "Audio Synchronization" + "lip_sync": "Lip synchronization", + "audio_sync": "Audio synchronization" } } } From 0479fc6f54c03d3fe4cc6362d2e063a1e05a79f3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Apr 2025 19:35:30 +0200 Subject: [PATCH 109/116] Remove redundant logging from UptimeRobot config_flow (#142940) --- homeassistant/components/uptimerobot/config_flow.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index ffe3c3e4563..5fc165c0f27 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -44,11 +44,9 @@ class UptimeRobotConfigFlow(ConfigFlow, domain=DOMAIN): try: response = await uptime_robot_api.async_get_account_details() - except UptimeRobotAuthenticationException as exception: - LOGGER.error(exception) + except UptimeRobotAuthenticationException: errors["base"] = "invalid_api_key" - except UptimeRobotException as exception: - LOGGER.error(exception) + except UptimeRobotException: errors["base"] = "cannot_connect" except Exception as exception: # noqa: BLE001 LOGGER.exception(exception) From 198a6b2e8feaefb1fd0c97bd7a6c6f18446b6416 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Apr 2025 19:37:01 +0200 Subject: [PATCH 110/116] Add missing strings to UptimeRobot (#142921) --- homeassistant/components/uptimerobot/strings.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index 588dc3ebf5c..6bcd1554b16 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -2,16 +2,20 @@ "config": { "step": { "user": { - "description": "You need to supply the 'main' API key from UptimeRobot", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The 'main' API key for your UptimeRobot account" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "You need to supply a new 'main' API key from UptimeRobot", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptimerobot::config::step::user::data_description::api_key%]" } } }, From 870350b9618d88a2c694944b51f7fc6182b83080 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Apr 2025 07:45:09 -1000 Subject: [PATCH 111/116] Add async_has_entity_registry_updated_listeners (#142772) --- homeassistant/helpers/event.py | 6 ++++++ tests/helpers/test_event.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b363bc21e86..baf1f144a3f 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -551,6 +551,12 @@ def async_track_entity_registry_updated_event( ) +@callback +def async_has_entity_registry_updated_listeners(hass: HomeAssistant) -> bool: + """Check if async_track_entity_registry_updated_event has been called yet.""" + return _KEYED_TRACK_ENTITY_REGISTRY_UPDATED.key in hass.data + + @callback def _async_device_registry_updated_filter( hass: HomeAssistant, diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a8691771580..b8bc89e29d7 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -30,6 +30,7 @@ from homeassistant.helpers.event import ( TrackTemplate, TrackTemplateResult, async_call_later, + async_has_entity_registry_updated_listeners, async_track_device_registry_updated_event, async_track_entity_registry_updated_event, async_track_point_in_time, @@ -4682,12 +4683,17 @@ async def test_async_track_entity_registry_updated_event(hass: HomeAssistant) -> def run_callback(event): event_data.append(event.data) + assert async_has_entity_registry_updated_listeners(hass) is False + unsub1 = async_track_entity_registry_updated_event( hass, entity_id, run_callback, job_type=ha.HassJobType.Callback ) unsub2 = async_track_entity_registry_updated_event( hass, new_entity_id, run_callback ) + + assert async_has_entity_registry_updated_listeners(hass) is True + hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} ) From 40fd7cf85220a33f1fcc6b8f4085e65ff2ed4c79 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 14 Apr 2025 20:12:34 +0200 Subject: [PATCH 112/116] Select correct Reolink device uid (#142864) * Select correct device_uid * Fix styling * restructure * Add test * Update test_util.py * Add explanation string --- homeassistant/components/reolink/__init__.py | 16 ++++++++ homeassistant/components/reolink/util.py | 12 ++++-- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_util.py | 41 +++++++++++++++++++- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index c326f1120c9..f7d13c1d90f 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -371,6 +371,9 @@ def migrate_entity_ids( new_device_id = f"{host.unique_id}" else: new_device_id = f"{host.unique_id}_{device_uid[1]}" + _LOGGER.debug( + "Updating Reolink device UID from %s to %s", device_uid, new_device_id + ) new_identifiers = {(DOMAIN, new_device_id)} device_reg.async_update_device(device.id, new_identifiers=new_identifiers) @@ -383,6 +386,9 @@ def migrate_entity_ids( new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}" else: new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}" + _LOGGER.debug( + "Updating Reolink device UID from %s to %s", device_uid, new_device_id + ) new_identifiers = {(DOMAIN, new_device_id)} existing_device = device_reg.async_get_device(identifiers=new_identifiers) if existing_device is None: @@ -415,6 +421,11 @@ def migrate_entity_ids( host.unique_id ): new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}" + _LOGGER.debug( + "Updating Reolink entity unique_id from %s to %s", + entity.unique_id, + new_id, + ) entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) if entity.device_id in ch_device_ids: @@ -430,6 +441,11 @@ def migrate_entity_ids( continue if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch): new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}" + _LOGGER.debug( + "Updating Reolink entity unique_id from %s to %s", + entity.unique_id, + new_id, + ) existing_entity = entity_reg.async_get_entity_id( entity.domain, entity.platform, new_id ) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index 12b4825caeb..17e666ac52c 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -79,11 +79,15 @@ def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: """Get the channel and the split device_uid from a reolink DeviceEntry.""" - device_uid = [ - dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN - ][0] - + device_uid = [] is_chime = False + + for dev_id in device.identifiers: + if dev_id[0] == DOMAIN: + device_uid = dev_id[1].split("_") + if device_uid[0] == host.unique_id: + break + if len(device_uid) < 2: # NVR itself ch = None diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 21acced3d1d..3bd1539fc36 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -77,6 +77,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True + host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC host_mock.uid = TEST_UID diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index ef66d471801..181249b8bff 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -23,15 +23,21 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) +from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.reolink.util import get_device_uid_and_ch from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr -from .conftest import TEST_NVR_NAME +from .conftest import TEST_NVR_NAME, TEST_UID, TEST_UID_CAM from tests.common import MockConfigEntry +DEV_ID_NVR = f"{TEST_UID}_{TEST_UID_CAM}" +DEV_ID_STANDALONE_CAM = f"{TEST_UID_CAM}" + @pytest.mark.parametrize( ("side_effect", "expected"), @@ -123,3 +129,36 @@ async def test_try_function( assert err.value.translation_key == expected.translation_key reolink_connect.set_volume.reset_mock(side_effect=True) + + +@pytest.mark.parametrize( + ("identifiers"), + [ + ({(DOMAIN, DEV_ID_NVR), (DOMAIN, DEV_ID_STANDALONE_CAM)}), + ({(DOMAIN, DEV_ID_STANDALONE_CAM), (DOMAIN, DEV_ID_NVR)}), + ], +) +async def test_get_device_uid_and_ch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + device_registry: dr.DeviceRegistry, + identifiers: set[tuple[str, str]], +) -> None: + """Test get_device_uid_and_ch with multiple identifiers.""" + reolink_connect.channels = [0] + + dev_entry = device_registry.async_get_or_create( + identifiers=identifiers, + config_entry_id=config_entry.entry_id, + disabled_by=None, + ) + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_device_uid_and_ch(dev_entry, config_entry.runtime_data.host) + # always get the uid and channel form the DEV_ID_NVR since is_nvr = True + assert result == ([TEST_UID, TEST_UID_CAM], 0, False) From 42345d9a06adafa881c8c26a696525697e98054a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 20:21:40 +0200 Subject: [PATCH 113/116] Use common states for "Auto"/"Manual" in `huawei_lte` (#142943) --- homeassistant/components/huawei_lte/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 879c7215562..912bc174dd5 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -272,8 +272,8 @@ "operator_search_mode": { "name": "Operator search mode", "state": { - "0": "Auto", - "1": "Manual" + "0": "[%key:common::state::auto%]", + "1": "[%key:common::state::manual%]" } }, "preferred_network_mode": { From e44d86479eb86ff2d4d164f0f4c8e4677d052d11 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 20:40:38 +0200 Subject: [PATCH 114/116] Use common state for "Auto" in `airzone_cloud` (#142944) --- homeassistant/components/airzone_cloud/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index 5dbd4384386..5481bfbc984 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -34,7 +34,7 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "auto": "Auto" + "auto": "[%key:common::state::auto%]" } }, "modes": { From b4a3470cb94db70bd83c43542f36901f764960cb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 20:50:59 +0200 Subject: [PATCH 115/116] Use common states for "Auto" and "High" in `palazzetti` (#142945) --- homeassistant/components/palazzetti/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index 7a6c47796df..59a2ba1ffe9 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -52,8 +52,8 @@ "fan_mode": { "state": { "silent": "Silent", - "auto": "Auto", - "high": "High" + "auto": "[%key:common::state::auto%]", + "high": "[%key:common::state::high%]" } } } From cf467b85932e00027c55c5f96af03b0ca791a8e8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 14 Apr 2025 20:57:15 +0200 Subject: [PATCH 116/116] Use common state for "Auto" in `sensibo` (#142941) --- homeassistant/components/sensibo/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index e7a440b4910..4dce104d1c7 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -139,7 +139,7 @@ "fanlevel": { "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", "medium_low": "Medium low", @@ -175,10 +175,10 @@ "name": "Mode", "state": { "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "heat": "[%key:component::climate::entity_component::_::state::heat%]", "cool": "[%key:component::climate::entity_component::_::state::cool%]", "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", - "auto": "[%key:component::climate::entity_component::_::state::auto%]", "dry": "[%key:component::climate::entity_component::_::state::dry%]", "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" } @@ -225,7 +225,7 @@ "fanlevel": { "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", "state": { - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "auto": "[%key:common::state::auto%]", "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", @@ -261,10 +261,10 @@ "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::mode::name%]", "state": { "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "heat": "[%key:component::climate::entity_component::_::state::heat%]", "cool": "[%key:component::climate::entity_component::_::state::cool%]", "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", - "auto": "[%key:component::climate::entity_component::_::state::auto%]", "dry": "[%key:component::climate::entity_component::_::state::dry%]", "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" } @@ -369,7 +369,7 @@ "medium": "[%key:common::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", "high": "[%key:common::state::high%]", - "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" + "auto": "[%key:common::state::auto%]" } }, "swing_mode": { @@ -536,12 +536,12 @@ }, "hvac_mode": { "options": { + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", "cool": "[%key:component::climate::entity_component::_::state::cool%]", "heat": "[%key:component::climate::entity_component::_::state::heat%]", "fan": "[%key:component::climate::entity_component::_::state::fan_only%]", - "auto": "[%key:component::climate::entity_component::_::state::auto%]", - "dry": "[%key:component::climate::entity_component::_::state::dry%]", - "off": "[%key:common::state::off%]" + "dry": "[%key:component::climate::entity_component::_::state::dry%]" } }, "light_mode": {