From d0c5f291fc3cf2a99791da70a3fa6e1140b8bffa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 26 Aug 2025 11:46:53 +0200 Subject: [PATCH 01/20] Add Tuya test fixtures (#151185) --- tests/components/tuya/__init__.py | 2 + .../tuya/fixtures/swtz_3rzngbyy.json | 116 +++++++++++++++ .../tuya/fixtures/xdd_shx9mmadyyeaq88t.json | 139 ++++++++++++++++++ .../components/tuya/snapshots/test_init.ambr | 62 ++++++++ .../components/tuya/snapshots/test_light.ambr | 83 +++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++++++ 6 files changed, 450 insertions(+) create mode 100644 tests/components/tuya/fixtures/swtz_3rzngbyy.json create mode 100644 tests/components/tuya/fixtures/xdd_shx9mmadyyeaq88t.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1fdc28bcb9f1b7..0b1a8793228f82 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -179,6 +179,7 @@ "sp_rjKXWRohlvOTyLBu", # https://github.com/home-assistant/core/issues/149704 "sp_rudejjigkywujjvs", # https://github.com/home-assistant/core/issues/146164 "sp_sdd5f5f2dl5wydjf", # https://github.com/home-assistant/core/issues/144087 + "swtz_3rzngbyy", # https://github.com/orgs/home-assistant/discussions/688 "tdq_1aegphq4yfd50e6b", # https://github.com/home-assistant/core/issues/143209 "tdq_9htyiowaf5rtdhrv", # https://github.com/home-assistant/core/issues/143209 "tdq_cq1p0nt0a4rixnex", # https://github.com/home-assistant/core/issues/146845 @@ -215,6 +216,7 @@ "wsdcg_yqiqbaldtr0i7mru", # https://github.com/home-assistant/core/issues/136223 "wxkg_ja5osu5g", # https://github.com/orgs/home-assistant/discussions/482 "wxkg_l8yaz4um5b3pwyvf", # https://github.com/home-assistant/core/issues/93975 + "xdd_shx9mmadyyeaq88t", # https://github.com/home-assistant/core/issues/151141 "ydkt_jevroj5aguwdbs2e", # https://github.com/orgs/home-assistant/discussions/288 "ygsb_l6ax0u6jwbz82atk", # https://github.com/home-assistant/core/issues/146319 "ykq_bngwdjsr", # https://github.com/orgs/home-assistant/discussions/482 diff --git a/tests/components/tuya/fixtures/swtz_3rzngbyy.json b/tests/components/tuya/fixtures/swtz_3rzngbyy.json new file mode 100644 index 00000000000000..9eab932c4cbb9c --- /dev/null +++ b/tests/components/tuya/fixtures/swtz_3rzngbyy.json @@ -0,0 +1,116 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Grillh\u0151m\u00e9r\u0151", + "category": "swtz", + "product_id": "3rzngbyy", + "product_name": "Cooking Thermometer", + "online": false, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-08-17T19:59:22+00:00", + "create_time": "2025-08-17T19:59:22+00:00", + "update_time": "2025-08-17T19:59:22+00:00", + "function": { + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "cook_temperature_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "temp_current_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "cook_temperature_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -300, + "max": 3000, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "battery_percentage": 100, + "temp_current": 290, + "temp_current_2": 290, + "cook_temperature": -300, + "cook_temperature_2": -300, + "temp_unit_convert": "c" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/xdd_shx9mmadyyeaq88t.json b/tests/components/tuya/fixtures/xdd_shx9mmadyyeaq88t.json new file mode 100644 index 00000000000000..99bc7f7b256d84 --- /dev/null +++ b/tests/components/tuya/fixtures/xdd_shx9mmadyyeaq88t.json @@ -0,0 +1,139 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Plafond bureau ", + "category": "xdd", + "product_id": "shx9mmadyyeaq88t", + "product_name": "Five way ceiling lamp", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-11-10T15:45:03+00:00", + "create_time": "2023-11-10T15:45:03+00:00", + "update_time": "2023-11-10T15:45:03+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "control_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value": 946, + "temp_value": 689, + "colour_data": { + "h": 308, + "s": 381, + "v": 1000 + }, + "countdown": 0, + "do_not_disturb": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index a70d38c6fbca91..12e43619d9ea8b 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -5672,6 +5672,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[t88qaeyydamm9xhsddx] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 't88qaeyydamm9xhsddx', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Five way ceiling lamp', + 'model_id': 'shx9mmadyyeaq88t', + 'name': 'Plafond bureau ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[tcdk0skzcpisexj2zc] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6416,6 +6447,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[yybgnzr3ztws] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'yybgnzr3ztws', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Cooking Thermometer (unsupported)', + 'model_id': '3rzngbyy', + 'name': 'Grillhőmérő', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[z7cu5t8bl9tt9fabjd] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index c04cee4a46dff1..c84d14d2de3c32 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -2347,6 +2347,89 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.plafond_bureau-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.plafond_bureau', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.t88qaeyydamm9xhsddxswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.plafond_bureau-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 241, + 'color_mode': , + 'color_temp': 260, + 'color_temp_kelvin': 3832, + 'friendly_name': 'Plafond bureau ', + 'hs_color': tuple( + 26.903, + 38.001, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 202, + 158, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.43, + 0.368, + ), + }), + 'context': , + 'entity_id': 'light.plafond_bureau', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[light.pokerlamp_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 97ba2e47e110bb..9a737c1a748771 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -5948,6 +5948,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.plafond_bureau_do_not_disturb-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.plafond_bureau_do_not_disturb', + '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': 'Do not disturb', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb', + 'unique_id': 'tuya.t88qaeyydamm9xhsddxdo_not_disturb', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.plafond_bureau_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plafond bureau Do not disturb', + }), + 'context': , + 'entity_id': 'switch.plafond_bureau_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.powerplug_5_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 47dbf923ed538b80323005c85307045f6013d150 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 26 Aug 2025 11:47:50 +0200 Subject: [PATCH 02/20] Bump to homematicip 2.3.0 (#151182) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 14b5ac393100ea..1470d1147ab728 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==2.2.0"] + "requirements": ["homematicip==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cefe1f55ea1b16..08d57fe37ed59c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1180,7 +1180,7 @@ home-assistant-frontend==20250811.1 home-assistant-intents==2025.7.30 # homeassistant.components.homematicip_cloud -homematicip==2.2.0 +homematicip==2.3.0 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0af37ec0c1650a..a3fc42c3d2d50d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1029,7 +1029,7 @@ home-assistant-frontend==20250811.1 home-assistant-intents==2025.7.30 # homeassistant.components.homematicip_cloud -homematicip==2.2.0 +homematicip==2.3.0 # homeassistant.components.remember_the_milk httplib2==0.20.4 From df9b0432b91c6535abb82bea20cd36d514d371dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 26 Aug 2025 11:48:09 +0200 Subject: [PATCH 03/20] Bump aiohomeconnect to 0.19.0 (#151180) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 2008e618f5e9c0..1a2761aa65fc52 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -22,6 +22,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "quality_scale": "platinum", - "requirements": ["aiohomeconnect==0.18.1"], + "requirements": ["aiohomeconnect==0.19.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 08d57fe37ed59c..c4c767ab89712f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -268,7 +268,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.2b0 # homeassistant.components.home_connect -aiohomeconnect==0.18.1 +aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller aiohomekit==3.2.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3fc42c3d2d50d..52a80366effefe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -253,7 +253,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.2b0 # homeassistant.components.home_connect -aiohomeconnect==0.18.1 +aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller aiohomekit==3.2.15 From 04f00d701012d0a77b76021d11048d0fa5f166ba Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 26 Aug 2025 02:52:18 -0700 Subject: [PATCH 04/20] Bump opower to 0.15.3 (#151179) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index e127824ac1919e..a3f29071ce9675 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.15.2"] + "requirements": ["opower==0.15.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index c4c767ab89712f..7e8d5ea04b53ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1625,7 +1625,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.15.2 +opower==0.15.3 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52a80366effefe..91cff93d6e5bed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1381,7 +1381,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.15.2 +opower==0.15.3 # homeassistant.components.oralb oralb-ble==0.17.6 From 32645bead0c26b05e725c0e3e7a04bfb7a7dfb2f Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 26 Aug 2025 11:01:31 +0100 Subject: [PATCH 05/20] Bump pytouchlinesl to 0.5.0 (#151140) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index 5140584f7ff567..335559eeae9587 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.4.0"] + "requirements": ["pytouchlinesl==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7e8d5ea04b53ee..222d20428afdaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2540,7 +2540,7 @@ pytomorrowio==0.3.6 pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl -pytouchlinesl==0.4.0 +pytouchlinesl==0.5.0 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91cff93d6e5bed..b66a26ca42b478 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2101,7 +2101,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.4.0 +pytouchlinesl==0.5.0 # homeassistant.components.traccar # homeassistant.components.traccar_server From 340a5f92e557ece7ef47bda4d5663b8f7602961c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:06:17 +0200 Subject: [PATCH 06/20] Add battery and tamper to Tuya siren (#151132) --- .../components/tuya/binary_sensor.py | 3 ++ homeassistant/components/tuya/sensor.py | 3 ++ .../tuya/snapshots/test_binary_sensor.ambr | 49 +++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 53 +++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index f9bc973f5a1eea..3119bd5793f2ac 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -285,6 +285,9 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": (TAMPER_BINARY_SENSOR,), # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index fe7db2b28b9650..d464bb1b566a09 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1057,6 +1057,9 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": BATTERY_SENSORS, # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": BATTERY_SENSORS, diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index ad1838b675560a..2a032b1577c8eb 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1175,6 +1175,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.siren_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.siren_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.okwwus27jhqqe2mijbgstemper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.siren_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Siren Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.siren_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.smart_thermostats_valve-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 6c11d6034b8f67..2a3a93b1b3e751 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -10920,6 +10920,59 @@ 'state': '2357.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.siren_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.siren_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.okwwus27jhqqe2mijbgsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.siren_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Siren Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.siren_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '77.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4c166c2320ecf281b7668c9a358b4ac9acb4afa2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 26 Aug 2025 12:11:33 +0200 Subject: [PATCH 07/20] Bump reolink-aio to 0.14.7 (#151045) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/reolink/entity.py | 1 + homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/number.py | 2 ++ homeassistant/components/reolink/select.py | 1 + homeassistant/components/reolink/switch.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 971b7ec4be1cb6..7d290dc6f0add7 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -253,6 +253,7 @@ def __init__( coordinator: DataUpdateCoordinator[None] | None = None, ) -> None: """Initialize ReolinkChimeCoordinatorEntity for a chime.""" + assert chime.channel is not None super().__init__(reolink_data, chime.channel, coordinator) self._chime = chime diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 4ad80dda8073c4..754ed780cee874 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.6"] + "requirements": ["reolink-aio==0.14.7"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index da879194e8892d..8f39fcd4880963 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -816,6 +816,8 @@ async def async_setup_entry( ReolinkChimeNumberEntity(reolink_data, chime, entity_description) for entity_description in CHIME_NUMBER_ENTITIES for chime in api.chime_list + for chime in api.chime_list + if chime.channel is not None ) async_add_entities(entities) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 242ea784cd9a35..35ed3dbb70e1ad 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -381,6 +381,7 @@ async def async_setup_entry( for entity_description in CHIME_SELECT_ENTITIES for chime in reolink_data.host.api.chime_list if entity_description.supported(chime) + if entity_description.supported(chime) and chime.channel is not None ) async_add_entities(entities) diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 00934bc9777a30..bf18be7b837fa6 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -381,6 +381,7 @@ async def async_setup_entry( ReolinkChimeSwitchEntity(reolink_data, chime, entity_description) for entity_description in CHIME_SWITCH_ENTITIES for chime in reolink_data.host.api.chime_list + if chime.channel is not None ) # Can be removed in HA 2025.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index 222d20428afdaa..45234daef05fae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2661,7 +2661,7 @@ renault-api==0.4.0 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.6 +reolink-aio==0.14.7 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b66a26ca42b478..cc3a899bd12f6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2207,7 +2207,7 @@ renault-api==0.4.0 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.6 +reolink-aio==0.14.7 # homeassistant.components.rflink rflink==0.0.67 From 0031bce832f1c07343d9670a09d357314e65d8bf Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 26 Aug 2025 12:15:46 +0200 Subject: [PATCH 08/20] Fix async_migrate_entry for Alexa Devices (#151038) --- homeassistant/components/alexa_devices/__init__.py | 10 +++++----- homeassistant/components/alexa_devices/config_flow.py | 2 +- tests/components/alexa_devices/conftest.py | 2 ++ .../alexa_devices/snapshots/test_diagnostics.ambr | 2 +- tests/components/alexa_devices/test_init.py | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index c08e2f1c010781..7a267579f98929 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Migrate old entry.""" - if entry.version == 1 and entry.minor_version == 0: + if entry.version == 1 and entry.minor_version == 1: _LOGGER.debug( "Migrating from version %s.%s", entry.version, entry.minor_version ) @@ -56,12 +56,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> new_data.update({"site": f"https://www.amazon.{domain}"}) hass.config_entries.async_update_entry( - entry, data=new_data, version=1, minor_version=1 + entry, data=new_data, version=1, minor_version=2 ) - _LOGGER.info( - "Migration to version %s.%s successful", entry.version, entry.minor_version - ) + _LOGGER.info( + "Migration to version %s.%s successful", entry.version, entry.minor_version + ) return True diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index d75ba39323dab0..ccf18fd45585fd 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -52,7 +52,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Alexa Devices.""" VERSION = 1 - MINOR_VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 3c68b7b76261cd..cb88339fe83210 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -85,4 +85,6 @@ def mock_config_entry() -> MockConfigEntry: CONF_LOGIN_DATA: {"session": "test-session"}, }, unique_id=TEST_USERNAME, + version=1, + minor_version=2, ) diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 0f3c3647e90a86..6f9dc9a5cc3e0a 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -57,7 +57,7 @@ 'discovery_keys': dict({ }), 'domain': 'alexa_devices', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index c628a5e00e7b11..e809f002321b1d 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -49,7 +49,7 @@ async def test_migrate_entry( }, unique_id=TEST_USERNAME, version=1, - minor_version=0, + minor_version=1, ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -57,5 +57,5 @@ async def test_migrate_entry( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.minor_version == 1 + assert config_entry.minor_version == 2 assert config_entry.data["site"] == f"https://www.amazon.{TEST_COUNTRY}" From 060f7482874f19b4f6345dd6b73826a376dad60f Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Tue, 26 Aug 2025 03:20:14 -0700 Subject: [PATCH 09/20] Update iaqualink to 0.6.0 (#151176) --- 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 a0742865438540..a0a38e773f788f 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.2.0"], + "requirements": ["iaqualink==0.6.0", "h2==4.2.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 45234daef05fae..9ededd0acb74de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ hyperion-py==0.7.6 iammeter==0.2.1 # homeassistant.components.iaqualink -iaqualink==0.5.3 +iaqualink==0.6.0 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc3a899bd12f6a..e757a5457643e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1044,7 +1044,7 @@ huum==0.8.1 hyperion-py==0.7.6 # homeassistant.components.iaqualink -iaqualink==0.5.3 +iaqualink==0.6.0 # homeassistant.components.ibeacon ibeacon-ble==1.2.0 From e5f163fa569a1cef520cc8fd971ec9f1b9ee9b62 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 26 Aug 2025 12:20:43 +0200 Subject: [PATCH 10/20] Add clear cache button to Fully Kiosk integration (#150943) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/fully_kiosk/button.py | 6 ++++++ homeassistant/components/fully_kiosk/strings.json | 3 +++ tests/components/fully_kiosk/test_button.py | 11 +++++++++++ 3 files changed, 20 insertions(+) diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index 112ead983b9174..625a965a0dabeb 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -62,6 +62,12 @@ class FullyButtonEntityDescription(ButtonEntityDescription): entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.loadStartUrl(), ), + FullyButtonEntityDescription( + key="clearCache", + translation_key="clear_cache", + entity_category=EntityCategory.CONFIG, + press_action=lambda fully: fully.clearCache(), + ), ) diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index fdfdf7910ae82c..fd7eaecd44656a 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -69,6 +69,9 @@ }, "load_start_url": { "name": "Load start URL" + }, + "clear_cache": { + "name": "Clear browser cache" } }, "image": { diff --git a/tests/components/fully_kiosk/test_button.py b/tests/components/fully_kiosk/test_button.py index 4652ee9604723d..e263cbc70827b0 100644 --- a/tests/components/fully_kiosk/test_button.py +++ b/tests/components/fully_kiosk/test_button.py @@ -74,6 +74,17 @@ async def test_buttons( ) assert len(mock_fully_kiosk.loadStartUrl.mock_calls) == 1 + entry = entity_registry.async_get("button.amazon_fire_clear_browser_cache") + assert entry + assert entry.unique_id == "abcdef-123456-clearCache" + await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.amazon_fire_clear_browser_cache"}, + blocking=True, + ) + assert len(mock_fully_kiosk.clearCache.mock_calls) == 1 + assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry From 60e91555f894f976452e4a46557650c83a45d636 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 26 Aug 2025 03:21:20 -0700 Subject: [PATCH 11/20] Remove Arizona Public Service (APS) virtual integration (#150944) --- homeassistant/components/aps/__init__.py | 1 - homeassistant/components/aps/manifest.json | 6 ------ homeassistant/generated/integrations.json | 5 ----- 3 files changed, 12 deletions(-) delete mode 100644 homeassistant/components/aps/__init__.py delete mode 100644 homeassistant/components/aps/manifest.json diff --git a/homeassistant/components/aps/__init__.py b/homeassistant/components/aps/__init__.py deleted file mode 100644 index 7af88840958a33..00000000000000 --- a/homeassistant/components/aps/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Virtual integration: Arizona Public Service (APS).""" diff --git a/homeassistant/components/aps/manifest.json b/homeassistant/components/aps/manifest.json deleted file mode 100644 index 347fd74a7bf1eb..00000000000000 --- a/homeassistant/components/aps/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "domain": "aps", - "name": "Arizona Public Service (APS)", - "integration_type": "virtual", - "supported_by": "opower" -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 10f5ea45427c06..fc4cf30556b895 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -437,11 +437,6 @@ "config_flow": false, "iot_class": "cloud_push" }, - "aps": { - "name": "Arizona Public Service (APS)", - "integration_type": "virtual", - "supported_by": "opower" - }, "apsystems": { "name": "APsystems", "integration_type": "device", From e82d91d39489353b21da39aaee2d8ddb5928cfe3 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:23:33 +0200 Subject: [PATCH 12/20] Fix API field rename for Volvo integration (#151183) --- homeassistant/components/volvo/coordinator.py | 6 +++++- .../volvo/fixtures/ex30_2024/energy_capabilities.json | 2 +- .../fixtures/xc40_electric_2024/energy_capabilities.json | 2 +- .../volvo/fixtures/xc60_phev_2020/energy_capabilities.json | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index d6c8f349a52a2a..b0bf961815f5ec 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -269,8 +269,12 @@ async def _async_determine_api_calls( capabilities = await self.api.async_get_energy_capabilities() if capabilities.get("isSupported", False): + + def _normalize_key(key: str) -> str: + return "chargingStatus" if key == "chargingSystemStatus" else key + self._supported_capabilities = [ - key + _normalize_key(key) for key, value in capabilities.items() if isinstance(value, dict) and value.get("isSupported", False) ] diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json index f3aff11585d6c8..8a5545578b9d45 100644 --- a/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json +++ b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json @@ -9,7 +9,7 @@ "chargerConnectionStatus": { "isSupported": true }, - "chargingStatus": { + "chargingSystemStatus": { "isSupported": true }, "chargingType": { diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json index 3523d51e0717c4..968c759ab27480 100644 --- a/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json @@ -9,7 +9,7 @@ "chargerConnectionStatus": { "isSupported": true }, - "chargingStatus": { + "chargingSystemStatus": { "isSupported": true }, "chargingType": { diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json b/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json index 331795f545b705..d8aa07ff0bb65f 100644 --- a/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json +++ b/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json @@ -9,7 +9,7 @@ "chargerConnectionStatus": { "isSupported": true }, - "chargingStatus": { + "chargingSystemStatus": { "isSupported": true }, "chargingType": { From 4ee9eada41c34fee83dc6fe97aed7d6b400e89b2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 26 Aug 2025 12:23:47 +0200 Subject: [PATCH 13/20] Mark AI Task as integration type entity (#151188) --- homeassistant/components/ai_task/icons.json | 5 +++++ homeassistant/components/ai_task/manifest.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ai_task/icons.json b/homeassistant/components/ai_task/icons.json index 4a875e9fb11f06..24233372312d38 100644 --- a/homeassistant/components/ai_task/icons.json +++ b/homeassistant/components/ai_task/icons.json @@ -1,4 +1,9 @@ { + "entity_component": { + "_": { + "default": "mdi:star-four-points" + } + }, "services": { "generate_data": { "service": "mdi:file-star-four-points-outline" diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json index ea377ffa671f52..d05faf18055355 100644 --- a/homeassistant/components/ai_task/manifest.json +++ b/homeassistant/components/ai_task/manifest.json @@ -5,6 +5,6 @@ "codeowners": ["@home-assistant/core"], "dependencies": ["conversation", "media_source"], "documentation": "https://www.home-assistant.io/integrations/ai_task", - "integration_type": "system", + "integration_type": "entity", "quality_scale": "internal" } From c9876e2a2bc153ea1cbfc9ac2abd17a1c3c5358a Mon Sep 17 00:00:00 2001 From: markhannon Date: Tue, 26 Aug 2025 20:24:54 +1000 Subject: [PATCH 14/20] Fix support for blinds in zimi integration (#150729) Co-authored-by: Josef Zweck --- homeassistant/components/zimi/cover.py | 12 ++++--- tests/components/zimi/common.py | 3 +- tests/components/zimi/test_cover.py | 50 ++++++++++++++++++++------ 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/zimi/cover.py b/homeassistant/components/zimi/cover.py index 8f05e35e263c50..e39011ae0b963a 100644 --- a/homeassistant/components/zimi/cover.py +++ b/homeassistant/components/zimi/cover.py @@ -28,9 +28,11 @@ async def async_setup_entry( api = config_entry.runtime_data - doors = [ZimiCover(device, api) for device in api.doors] + covers = [ZimiCover(device, api) for device in api.blinds] - async_add_entities(doors) + covers.extend(ZimiCover(device, api) for device in api.doors) + + async_add_entities(covers) class ZimiCover(ZimiEntity, CoverEntity): @@ -81,9 +83,9 @@ async def async_open_cover(self, **kwargs: Any) -> None: async def async_set_cover_position(self, **kwargs: Any) -> None: """Open the cover/door to a specified percentage.""" - if position := kwargs.get("position"): - _LOGGER.debug("Sending set_cover_position(%d) for %s", position, self.name) - await self._device.open_to_percentage(position) + position = kwargs.get("position", 0) + _LOGGER.debug("Sending set_cover_position(%d) for %s", position, self.name) + await self._device.open_to_percentage(position) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" diff --git a/tests/components/zimi/common.py b/tests/components/zimi/common.py index 13582b3d42c685..50ffc0ac587b77 100644 --- a/tests/components/zimi/common.py +++ b/tests/components/zimi/common.py @@ -34,12 +34,13 @@ def mock_api_device( device_name: str | None = None, entity_type: str | None = None, + entity_id: str | None = None, ) -> MagicMock: """Mock a Zimi ControlPointDevice which is used in the zcc API with defaults.""" mock_api_device = create_autospec(ControlPointDevice) - mock_api_device.identifier = ENTITY_INFO["id"] + mock_api_device.identifier = entity_id or ENTITY_INFO["id"] mock_api_device.room = ENTITY_INFO["room"] mock_api_device.name = ENTITY_INFO["name"] mock_api_device.type = entity_type or ENTITY_INFO["type"] diff --git a/tests/components/zimi/test_cover.py b/tests/components/zimi/test_cover.py index 68809af49e6fe3..a12b59ada1365d 100644 --- a/tests/components/zimi/test_cover.py +++ b/tests/components/zimi/test_cover.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import ENTITY_INFO, mock_api_device, setup_platform +from .common import mock_api_device, setup_platform async def test_cover_entity( @@ -25,26 +25,54 @@ async def test_cover_entity( ) -> None: """Tests cover entity.""" - device_name = "Cover Controller" - entity_key = "cover.cover_controller_test_entity_name" + blind_device_name = "Blind Controller" + blind_entity_key = "cover.blind_controller_test_entity_name" + blind_entity_id = "test-entity-id-blind" + door_device_name = "Cover Controller" + door_entity_key = "cover.cover_controller_test_entity_name" + door_entity_id = "test-entity-id-door" entity_type = Platform.COVER - mock_api.doors = [mock_api_device(device_name=device_name, entity_type=entity_type)] + mock_api.blinds = [ + mock_api_device( + device_name=blind_device_name, + entity_type=entity_type, + entity_id=blind_entity_id, + ) + ] + mock_api.doors = [ + mock_api_device( + device_name=door_device_name, + entity_type=entity_type, + entity_id=door_entity_id, + ) + ] await setup_platform(hass, entity_type) - entity = entity_registry.entities[entity_key] - assert entity.unique_id == ENTITY_INFO["id"] + blind_entity = entity_registry.entities[blind_entity_key] + assert blind_entity.unique_id == blind_entity_id assert ( - entity.supported_features + blind_entity.supported_features == CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP | CoverEntityFeature.SET_POSITION ) - state = hass.states.get(entity_key) + door_entity = entity_registry.entities[door_entity_key] + assert door_entity.unique_id == door_entity_id + + assert ( + door_entity.supported_features + == CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + state = hass.states.get(door_entity_key) assert state == snapshot services = hass.services.async_services() @@ -53,7 +81,7 @@ async def test_cover_entity( await hass.services.async_call( entity_type, SERVICE_CLOSE_COVER, - {"entity_id": entity_key}, + {"entity_id": door_entity_key}, blocking=True, ) assert mock_api.doors[0].close_door.called @@ -62,7 +90,7 @@ async def test_cover_entity( await hass.services.async_call( entity_type, SERVICE_OPEN_COVER, - {"entity_id": entity_key}, + {"entity_id": door_entity_key}, blocking=True, ) assert mock_api.doors[0].open_door.called @@ -71,7 +99,7 @@ async def test_cover_entity( await hass.services.async_call( entity_type, SERVICE_SET_COVER_POSITION, - {"entity_id": entity_key, "position": 50}, + {"entity_id": door_entity_key, "position": 50}, blocking=True, ) assert mock_api.doors[0].open_to_percentage.called From dfbe42fb21b5c046b168ae0c7954be6422442346 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Tue, 26 Aug 2025 12:30:28 +0200 Subject: [PATCH 15/20] Use device id instead of archetype to check for Hue bridge (#151097) --- homeassistant/components/hue/v2/device.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 8979befcf734a2..62dbe94021712a 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -7,7 +7,7 @@ from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.groups import Room, Zone -from aiohue.v2.models.device import Device, DeviceArchetypes +from aiohue.v2.models.device import Device from aiohue.v2.models.resource import ResourceTypes from homeassistant.const import ( @@ -66,7 +66,7 @@ def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry: } if room := dev_controller.get_room(hue_resource.id): params[ATTR_SUGGESTED_AREA] = room.metadata.name - if hue_resource.metadata.archetype == DeviceArchetypes.BRIDGE_V2: + if hue_resource.id == api.config.bridge_device.id: params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id)) else: params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) @@ -97,9 +97,7 @@ def handle_device_event( # create/update all current devices found in controllers # sort the devices to ensure bridges are added first hue_devices = list(dev_controller) - hue_devices.sort( - key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2 - ) + hue_devices.sort(key=lambda dev: dev.id != api.config.bridge_device.id) known_devices = [add_device(hue_device) for hue_device in hue_devices] known_devices += [add_device(hue_room) for hue_room in api.groups.room] known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] From 5bb96f7f061757352c0a533d2f1d3fdaeec23eed Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Aug 2025 12:37:56 +0200 Subject: [PATCH 16/20] Adjust device disabled_by flag when changing config entry (#151155) --- .../components/anthropic/__init__.py | 12 +- .../__init__.py | 12 +- homeassistant/components/ollama/__init__.py | 12 +- .../openai_conversation/__init__.py | 12 +- homeassistant/helpers/device_registry.py | 26 ++ tests/components/anthropic/test_init.py | 28 +- .../test_init.py | 28 +- tests/components/ollama/test_init.py | 28 +- .../openai_conversation/test_init.py | 28 +- tests/helpers/test_device_registry.py | 260 ++++++++++++++++++ 10 files changed, 390 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index b996b7d38c57cf..55178d101fb458 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -129,9 +129,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY and not all_disabled ): - # Device and entity registries don't update the disabled_by flag - # when moving a device or entity from one config entry to another, - # so we need to do it manually. + # Device and entity registries will set the disabled_by flag to None + # when moving a device or entity disabled by CONFIG_ENTRY to an enabled + # config entry, but we want to set it to DEVICE or USER instead, entity_disabled_by = ( er.RegistryEntryDisabler.DEVICE if device @@ -146,9 +146,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if device is not None: - # Device and entity registries don't update the disabled_by flag when - # moving a device or entity from one config entry to another, so we - # need to do it manually. + # Device and entity registries will set the disabled_by flag to None + # when moving a device or entity disabled by CONFIG_ENTRY to an enabled + # config entry, but we want to set it to USER instead, device_disabled_by = device.disabled_by if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index a1fd5ea0f9b756..8d7fb1b1cc4cb4 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -260,9 +260,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY and not all_disabled ): - # Device and entity registries don't update the disabled_by flag - # when moving a device or entity from one config entry to another, - # so we need to do it manually. + # Device and entity registries will set the disabled_by flag to None + # when moving a device or entity disabled by CONFIG_ENTRY to an enabled + # config entry, but we want to set it to DEVICE or USER instead, entity_disabled_by = ( er.RegistryEntryDisabler.DEVICE if device @@ -277,9 +277,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if device is not None: - # Device and entity registries don't update the disabled_by flag when - # moving a device or entity from one config entry to another, so we - # need to do it manually. + # Device and entity registries will set the disabled_by flag to None + # when moving a device or entity disabled by CONFIG_ENTRY to an enabled + # config entry, but we want to set it to USER instead, device_disabled_by = device.disabled_by if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 091e58dbe7f717..805724b82e3973 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -145,9 +145,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY and not all_disabled ): - # Device and entity registries don't update the disabled_by flag - # when moving a device or entity from one config entry to another, - # so we need to do it manually. + # Device and entity registries will set the disabled_by flag to None + # when moving a device or entity disabled by CONFIG_ENTRY to an enabled + # config entry, but we want to set it to DEVICE or USER instead, entity_disabled_by = ( er.RegistryEntryDisabler.DEVICE if device @@ -162,9 +162,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if device is not None: - # Device and entity registries don't update the disabled_by flag when - # moving a device or entity from one config entry to another, so we - # need to do it manually. + # Device and entity registries will set the disabled_by flag to None + # when moving a device or entity disabled by CONFIG_ENTRY to an enabled + # config entry, but we want to set it to USER instead, device_disabled_by = device.disabled_by if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index f50563b59ea1b7..06a61d70b01113 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -320,9 +320,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY and not all_disabled ): - # Device and entity registries don't update the disabled_by flag - # when moving a device or entity from one config entry to another, - # so we need to do it manually. + # Device and entity registries will set the disabled_by flag to None + # when moving a device or entity disabled by CONFIG_ENTRY to an enabled + # config entry, but we want to set it to DEVICE or USER instead, entity_disabled_by = ( er.RegistryEntryDisabler.DEVICE if device @@ -337,9 +337,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if device is not None: - # Device and entity registries don't update the disabled_by flag when - # moving a device or entity from one config entry to another, so we - # need to do it manually. + # Device and entity registries will set the disabled_by flag to None + # when moving a device or entity disabled by CONFIG_ENTRY to an enabled + # config entry, but we want to set it to USER instead, device_disabled_by = device.disabled_by if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index a78fa935606766..e25ca11e08330f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1115,6 +1115,16 @@ def async_update_device( # noqa: C901 config_entries_subentries = old.config_entries_subentries | { add_config_entry_id: {add_config_subentry_id} } + # Enable the device if it was disabled by config entry and we're adding + # a non disabled config entry + if ( + # mypy says add_config_entry can be None. That's impossible, because we + # raise above if that happens + not add_config_entry.disabled_by # type: ignore[union-attr] + and old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY + ): + new_values["disabled_by"] = None + old_values["disabled_by"] = old.disabled_by elif ( add_config_subentry_id not in old.config_entries_subentries[add_config_entry_id] @@ -1157,6 +1167,22 @@ def async_update_device( # noqa: C901 config_entries = config_entries - {remove_config_entry_id} + # Disable the device if it is enabled and all remaining config entries + # are disabled + has_enabled_config_entries = any( + config_entry.disabled_by is None + for config_entry_id in config_entries + if ( + config_entry := self.hass.config_entries.async_get_entry( + config_entry_id + ) + ) + is not None + ) + if not has_enabled_config_entries and old.disabled_by is None: + new_values["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY + old_values["disabled_by"] = old.disabled_by + if config_entries != old.config_entries: new_values["config_entries"] = config_entries old_values["config_entries"] = old.config_entries diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index ff54539bb39f93..a97a3b7a378010 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -156,6 +156,8 @@ async def test_migration_from_v1_to_v2( @pytest.mark.parametrize( ( "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", "merged_config_entry_disabled_by", "conversation_subentry_data", "main_config_entry", @@ -163,6 +165,8 @@ async def test_migration_from_v1_to_v2( [ ( [ConfigEntryDisabler.USER, None], + [DeviceEntryDisabler.CONFIG_ENTRY, None], + [RegistryEntryDisabler.CONFIG_ENTRY, None], None, [ { @@ -182,18 +186,20 @@ async def test_migration_from_v1_to_v2( ), ( [None, ConfigEntryDisabler.USER], + [None, DeviceEntryDisabler.CONFIG_ENTRY], + [None, RegistryEntryDisabler.CONFIG_ENTRY], None, [ { "conversation_entity_id": "conversation.claude", - "device_disabled_by": DeviceEntryDisabler.USER, - "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device_disabled_by": None, + "entity_disabled_by": None, "device": 0, }, { "conversation_entity_id": "conversation.claude_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, "device": 1, }, ], @@ -201,6 +207,8 @@ async def test_migration_from_v1_to_v2( ), ( [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + [DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY], + [RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY], ConfigEntryDisabler.USER, [ { @@ -211,8 +219,8 @@ async def test_migration_from_v1_to_v2( }, { "conversation_entity_id": "conversation.claude_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, "device": 1, }, ], @@ -225,6 +233,8 @@ async def test_migration_from_v1_disabled( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_disabled_by: list[ConfigEntryDisabler | None], + device_disabled_by: list[DeviceEntryDisabler | None], + entity_disabled_by: list[RegistryEntryDisabler | None], merged_config_entry_disabled_by: ConfigEntryDisabler | None, conversation_subentry_data: list[dict[str, Any]], main_config_entry: int, @@ -264,7 +274,7 @@ async def test_migration_from_v1_disabled( manufacturer="Anthropic", model="Claude", entry_type=dr.DeviceEntryType.SERVICE, - disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + disabled_by=device_disabled_by[0], ) entity_registry.async_get_or_create( "conversation", @@ -273,7 +283,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry, device_id=device_1.id, suggested_object_id="claude", - disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + disabled_by=entity_disabled_by[0], ) device_2 = device_registry.async_get_or_create( @@ -283,6 +293,7 @@ async def test_migration_from_v1_disabled( manufacturer="Anthropic", model="Claude", entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=device_disabled_by[1], ) entity_registry.async_get_or_create( "conversation", @@ -291,6 +302,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry_2, device_id=device_2.id, suggested_object_id="claude", + disabled_by=entity_disabled_by[1], ) devices = [device_1, device_2] diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index fbd52dc9245085..8098eed7f15bd2 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -576,6 +576,8 @@ async def test_migration_from_v1( @pytest.mark.parametrize( ( "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", "merged_config_entry_disabled_by", "conversation_subentry_data", "main_config_entry", @@ -583,6 +585,8 @@ async def test_migration_from_v1( [ ( [ConfigEntryDisabler.USER, None], + [DeviceEntryDisabler.CONFIG_ENTRY, None], + [RegistryEntryDisabler.CONFIG_ENTRY, None], None, [ { @@ -602,18 +606,20 @@ async def test_migration_from_v1( ), ( [None, ConfigEntryDisabler.USER], + [None, DeviceEntryDisabler.CONFIG_ENTRY], + [None, RegistryEntryDisabler.CONFIG_ENTRY], None, [ { "conversation_entity_id": "conversation.google_generative_ai_conversation", - "device_disabled_by": DeviceEntryDisabler.USER, - "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device_disabled_by": None, + "entity_disabled_by": None, "device": 0, }, { "conversation_entity_id": "conversation.google_generative_ai_conversation_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, "device": 1, }, ], @@ -621,6 +627,8 @@ async def test_migration_from_v1( ), ( [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + [DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY], + [RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY], ConfigEntryDisabler.USER, [ { @@ -631,8 +639,8 @@ async def test_migration_from_v1( }, { "conversation_entity_id": "conversation.google_generative_ai_conversation_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, "device": 1, }, ], @@ -645,6 +653,8 @@ async def test_migration_from_v1_disabled( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_disabled_by: list[ConfigEntryDisabler | None], + device_disabled_by: list[DeviceEntryDisabler | None], + entity_disabled_by: list[RegistryEntryDisabler | None], merged_config_entry_disabled_by: ConfigEntryDisabler | None, conversation_subentry_data: list[dict[str, Any]], main_config_entry: int, @@ -684,7 +694,7 @@ async def test_migration_from_v1_disabled( manufacturer="Google", model="Generative AI", entry_type=dr.DeviceEntryType.SERVICE, - disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + disabled_by=device_disabled_by[0], ) entity_registry.async_get_or_create( "conversation", @@ -693,7 +703,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry, device_id=device_1.id, suggested_object_id="google_generative_ai_conversation", - disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + disabled_by=entity_disabled_by[0], ) device_2 = device_registry.async_get_or_create( @@ -703,6 +713,7 @@ async def test_migration_from_v1_disabled( manufacturer="Google", model="Generative AI", entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=device_disabled_by[1], ) entity_registry.async_get_or_create( "conversation", @@ -711,6 +722,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry_2, device_id=device_2.id, suggested_object_id="google_generative_ai_conversation_2", + disabled_by=entity_disabled_by[1], ) devices = [device_1, device_2] diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 766de8a7d6d793..25e41daf2762bb 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -372,6 +372,8 @@ async def test_migration_from_v1_with_same_urls( @pytest.mark.parametrize( ( "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", "merged_config_entry_disabled_by", "conversation_subentry_data", "main_config_entry", @@ -379,6 +381,8 @@ async def test_migration_from_v1_with_same_urls( [ ( [ConfigEntryDisabler.USER, None], + [DeviceEntryDisabler.CONFIG_ENTRY, None], + [RegistryEntryDisabler.CONFIG_ENTRY, None], None, [ { @@ -398,18 +402,20 @@ async def test_migration_from_v1_with_same_urls( ), ( [None, ConfigEntryDisabler.USER], + [None, DeviceEntryDisabler.CONFIG_ENTRY], + [None, RegistryEntryDisabler.CONFIG_ENTRY], None, [ { "conversation_entity_id": "conversation.ollama", - "device_disabled_by": DeviceEntryDisabler.USER, - "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device_disabled_by": None, + "entity_disabled_by": None, "device": 0, }, { "conversation_entity_id": "conversation.ollama_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, "device": 1, }, ], @@ -417,6 +423,8 @@ async def test_migration_from_v1_with_same_urls( ), ( [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + [DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY], + [RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY], ConfigEntryDisabler.USER, [ { @@ -427,8 +435,8 @@ async def test_migration_from_v1_with_same_urls( }, { "conversation_entity_id": "conversation.ollama_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, "device": 1, }, ], @@ -441,6 +449,8 @@ async def test_migration_from_v1_disabled( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_disabled_by: list[ConfigEntryDisabler | None], + device_disabled_by: list[DeviceEntryDisabler | None], + entity_disabled_by: list[RegistryEntryDisabler | None], merged_config_entry_disabled_by: ConfigEntryDisabler | None, conversation_subentry_data: list[dict[str, Any]], main_config_entry: int, @@ -474,7 +484,7 @@ async def test_migration_from_v1_disabled( manufacturer="Ollama", model="Ollama", entry_type=dr.DeviceEntryType.SERVICE, - disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + disabled_by=device_disabled_by[0], ) entity_registry.async_get_or_create( "conversation", @@ -483,7 +493,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry, device_id=device_1.id, suggested_object_id="ollama", - disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + disabled_by=entity_disabled_by[0], ) device_2 = device_registry.async_get_or_create( @@ -493,6 +503,7 @@ async def test_migration_from_v1_disabled( manufacturer="Ollama", model="Ollama", entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=device_disabled_by[1], ) entity_registry.async_get_or_create( "conversation", @@ -501,6 +512,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry_2, device_id=device_2.id, suggested_object_id="ollama_2", + disabled_by=entity_disabled_by[1], ) devices = [device_1, device_2] diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 66afc41826b1d8..70d873752aed74 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -868,6 +868,8 @@ async def test_migration_from_v1_with_same_keys( @pytest.mark.parametrize( ( "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", "merged_config_entry_disabled_by", "conversation_subentry_data", "main_config_entry", @@ -875,6 +877,8 @@ async def test_migration_from_v1_with_same_keys( [ ( [ConfigEntryDisabler.USER, None], + [DeviceEntryDisabler.CONFIG_ENTRY, None], + [RegistryEntryDisabler.CONFIG_ENTRY, None], None, [ { @@ -894,18 +898,20 @@ async def test_migration_from_v1_with_same_keys( ), ( [None, ConfigEntryDisabler.USER], + [None, DeviceEntryDisabler.CONFIG_ENTRY], + [None, RegistryEntryDisabler.CONFIG_ENTRY], None, [ { "conversation_entity_id": "conversation.chatgpt", - "device_disabled_by": DeviceEntryDisabler.USER, - "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device_disabled_by": None, + "entity_disabled_by": None, "device": 0, }, { "conversation_entity_id": "conversation.chatgpt_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, "device": 1, }, ], @@ -913,6 +919,8 @@ async def test_migration_from_v1_with_same_keys( ), ( [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + [DeviceEntryDisabler.CONFIG_ENTRY, DeviceEntryDisabler.CONFIG_ENTRY], + [RegistryEntryDisabler.CONFIG_ENTRY, RegistryEntryDisabler.CONFIG_ENTRY], ConfigEntryDisabler.USER, [ { @@ -923,8 +931,8 @@ async def test_migration_from_v1_with_same_keys( }, { "conversation_entity_id": "conversation.chatgpt_2", - "device_disabled_by": None, - "entity_disabled_by": None, + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, "device": 1, }, ], @@ -937,6 +945,8 @@ async def test_migration_from_v1_disabled( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_disabled_by: list[ConfigEntryDisabler | None], + device_disabled_by: list[DeviceEntryDisabler | None], + entity_disabled_by: list[RegistryEntryDisabler | None], merged_config_entry_disabled_by: ConfigEntryDisabler | None, conversation_subentry_data: list[dict[str, Any]], main_config_entry: int, @@ -976,7 +986,7 @@ async def test_migration_from_v1_disabled( manufacturer="OpenAI", model="ChatGPT", entry_type=dr.DeviceEntryType.SERVICE, - disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + disabled_by=device_disabled_by[0], ) entity_registry.async_get_or_create( "conversation", @@ -985,7 +995,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry, device_id=device_1.id, suggested_object_id="chatgpt", - disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + disabled_by=entity_disabled_by[0], ) device_2 = device_registry.async_get_or_create( @@ -995,6 +1005,7 @@ async def test_migration_from_v1_disabled( manufacturer="OpenAI", model="ChatGPT", entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=device_disabled_by[1], ) entity_registry.async_get_or_create( "conversation", @@ -1003,6 +1014,7 @@ async def test_migration_from_v1_disabled( config_entry=mock_config_entry_2, device_id=device_2.id, suggested_object_id="chatgpt_2", + disabled_by=entity_disabled_by[1], ) devices = [device_1, device_2] diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 30fb22b8e0974f..80910d42630cb5 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -3279,6 +3279,266 @@ async def test_update_suggested_area( assert updated_entry.area_id == device_area_id +@pytest.mark.parametrize( + ( + "new_config_entry_disabled_by", + "device_disabled_by_initial", + "device_disabled_by_updated", + "extra_changes", + ), + [ + ( + None, + None, + None, + {}, + ), + # Config entry not disabled, device was disabled by config entry. + # Device not disabled when updated. + ( + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + None, + {"disabled_by": dr.DeviceEntryDisabler.CONFIG_ENTRY}, + ), + ( + None, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + {}, + ), + ( + None, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + None, + None, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + {}, + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_add_config_entry_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + new_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, + device_disabled_by_initial: dr.DeviceEntryDisabler | None, + device_disabled_by_updated: dr.DeviceEntryDisabler | None, + extra_changes: dict[str, Any], +) -> None: + """Check how the disabled_by flag is treated when adding a config entry.""" + config_entry_1 = MockConfigEntry(title=None) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + title=None, disabled_by=new_config_entry_disabled_by + ) + config_entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id=None, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by_initial, + ) + assert entry.disabled_by == device_disabled_by_initial + + entry2 = device_registry.async_update_device( + entry.id, add_config_entry_id=config_entry_2.entry_id + ) + + assert entry2 == dr.DeviceEntry( + config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, + config_entries_subentries={ + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + }, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=device_disabled_by_updated, + id=entry.id, + modified_at=utcnow(), + primary_config_entry=None, + ) + + await hass.async_block_till_done() + + assert len(update_events) == 2 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, + } + | extra_changes, + } + + +@pytest.mark.parametrize( + ( + "removed_config_entry_disabled_by", + "device_disabled_by_initial", + "device_disabled_by_updated", + "extra_changes", + ), + [ + # The non-disabled config entry is removed, device changed to + # disabled by config entry. + ( + None, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {"disabled_by": None}, + ), + ( + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {}, + ), + ( + None, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + {}, + ), + ( + None, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + {}, + ), + # In this test, the device is in an invalid state: config entry disabled, + # device not disabled. After removing the config entry, the device is disabled + # by checking the remaining config entry. + ( + config_entries.ConfigEntryDisabler.USER, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {"disabled_by": None}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.INTEGRATION, + {}, + ), + ( + config_entries.ConfigEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + {}, + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_remove_config_entry_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + removed_config_entry_disabled_by: config_entries.ConfigEntryDisabler | None, + device_disabled_by_initial: dr.DeviceEntryDisabler | None, + device_disabled_by_updated: dr.DeviceEntryDisabler | None, + extra_changes: dict[str, Any], +) -> None: + """Check how the disabled_by flag is treated when removing a config entry.""" + config_entry_1 = MockConfigEntry( + title=None, disabled_by=removed_config_entry_disabled_by + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + title=None, disabled_by=config_entries.ConfigEntryDisabler.USER + ) + config_entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id=None, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by_initial, + ) + assert entry.disabled_by == device_disabled_by_initial + + entry2 = device_registry.async_update_device( + entry.id, add_config_entry_id=config_entry_2.entry_id + ) + assert entry2.disabled_by == device_disabled_by_initial + + entry3 = device_registry.async_update_device( + entry.id, remove_config_entry_id=config_entry_1.entry_id + ) + + assert entry3 == dr.DeviceEntry( + config_entries={config_entry_2.entry_id}, + config_entries_subentries={config_entry_2.entry_id: {None}}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=device_disabled_by_updated, + id=entry.id, + modified_at=utcnow(), + primary_config_entry=None, + ) + + await hass.async_block_till_done() + + assert len(update_events) == 3 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, + }, + } + assert update_events[2].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + }, + } + | extra_changes, + } + + async def test_cleanup_device_registry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From ce523fc91dd7b09a213a29e0ee16640eb431ed65 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:44:07 +0200 Subject: [PATCH 17/20] Expose method to set last activated on scene (#146884) --- homeassistant/components/scene/__init__.py | 41 ++++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index d1b34b5077092f..b4e23a36d825d3 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -12,15 +12,16 @@ from homeassistant.components.light import ATTR_TRANSITION from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON, STATE_UNAVAILABLE -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey DOMAIN: Final = "scene" -DATA_COMPONENT: HassKey[EntityComponent[Scene]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[BaseScene]] = HassKey(DOMAIN) STATES: Final = "states" @@ -62,7 +63,7 @@ def _platform_validator(config: dict[str, Any]) -> dict[str, Any]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the scenes.""" - component = hass.data[DATA_COMPONENT] = EntityComponent[Scene]( + component = hass.data[DATA_COMPONENT] = EntityComponent[BaseScene]( logging.getLogger(__name__), DOMAIN, hass ) @@ -93,8 +94,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -class Scene(RestoreEntity): - """A scene is a group of entities and the states we want them to be.""" +class BaseScene(RestoreEntity): + """Base type for scenes.""" _attr_should_poll = False __last_activated: str | None = None @@ -108,14 +109,14 @@ def state(self) -> str | None: return self.__last_activated @final - async def _async_activate(self, **kwargs: Any) -> None: - """Activate scene. + def _record_activation(self) -> None: + run_callback_threadsafe(self.hass.loop, self._async_record_activation).result() - Should not be overridden, handle setting last press timestamp. - """ + @final + @callback + def _async_record_activation(self) -> None: + """Update the activation timestamp.""" self.__last_activated = dt_util.utcnow().isoformat() - self.async_write_ha_state() - await self.async_activate(**kwargs) async def async_internal_added_to_hass(self) -> None: """Call when the scene is added to hass.""" @@ -128,6 +129,10 @@ async def async_internal_added_to_hass(self) -> None: ): self.__last_activated = state.state + async def _async_activate(self, **kwargs: Any) -> None: + """Activate scene.""" + raise NotImplementedError + def activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" raise NotImplementedError @@ -137,3 +142,17 @@ async def async_activate(self, **kwargs: Any) -> None: task = self.hass.async_add_executor_job(ft.partial(self.activate, **kwargs)) if task: await task + + +class Scene(BaseScene): + """A scene is a group of entities and the states we want them to be.""" + + @final + async def _async_activate(self, **kwargs: Any) -> None: + """Activate scene. + + Should not be overridden, handle setting last press timestamp. + """ + self._async_record_activation() + self.async_write_ha_state() + await self.async_activate(**kwargs) From e2faa7020b826f8643c4f7c4fcc5210cf154fed7 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:59:08 +0200 Subject: [PATCH 18/20] Bumb python-homewizard-energy to 9.3.0 (#151187) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index f9924a68db46b0..0d06c96cff1abe 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==9.2.0"], + "requirements": ["python-homewizard-energy==9.3.0"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 9ededd0acb74de..0eb145ea908aae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2440,7 +2440,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==9.2.0 +python-homewizard-energy==9.3.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e757a5457643e2..f9e032d5b9118f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2019,7 +2019,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==9.2.0 +python-homewizard-energy==9.3.0 # homeassistant.components.izone python-izone==1.2.9 From a90ac612f0a162a0038332099a8b62a854b57c76 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Aug 2025 14:38:14 +0200 Subject: [PATCH 19/20] Allow dynamically creating menu options in SchemaFlowHandler (#151191) --- .../helpers/schema_config_entry_flow.py | 19 ++++++++++++++++--- .../helpers/test_schema_config_entry_flow.py | 6 ++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 8bc773d85f72d3..0ee406a7a19927 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -107,7 +107,15 @@ class SchemaFlowMenuStep(SchemaFlowStep): """Define a config or options flow menu step.""" # Menu options - options: Container[str] + options: ( + Container[str] + | Callable[[SchemaCommonFlowHandler], Coroutine[Any, Any, Container[str]]] + ) + """Menu options, or function which returns menu options. + + - If a function is specified, the function will be passed the current + `SchemaCommonFlowHandler`. + """ class SchemaCommonFlowHandler: @@ -152,6 +160,11 @@ async def async_step( return await self._async_form_step(step_id, user_input) return await self._async_menu_step(step_id, user_input) + async def _get_options(self, form_step: SchemaFlowMenuStep) -> Container[str]: + if isinstance(form_step.options, Container): + return form_step.options + return await form_step.options(self) + async def _get_schema(self, form_step: SchemaFlowFormStep) -> vol.Schema | None: if form_step.schema is None: return None @@ -255,7 +268,7 @@ async def _show_next_step( menu_step = cast(SchemaFlowMenuStep, self._flow[next_step_id]) return self._handler.async_show_menu( step_id=next_step_id, - menu_options=menu_step.options, + menu_options=await self._get_options(menu_step), ) form_step = cast(SchemaFlowFormStep, self._flow[next_step_id]) @@ -308,7 +321,7 @@ async def _async_menu_step( menu_step: SchemaFlowMenuStep = cast(SchemaFlowMenuStep, self._flow[step_id]) return self._handler.async_show_menu( step_id=step_id, - menu_options=menu_step.options, + menu_options=await self._get_options(menu_step), ) diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index e76faf9ee5265d..0ad21a1950ad3d 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -317,7 +317,9 @@ async def test_menu_step(hass: HomeAssistant) -> None: """Test menu step.""" MENU_1 = ["option1", "option2"] - MENU_2 = ["option3", "option4"] + + async def menu_2(handler: SchemaCommonFlowHandler) -> list[str]: + return ["option3", "option4"] async def _option1_next_step(_: dict[str, Any]) -> str: return "menu2" @@ -325,7 +327,7 @@ async def _option1_next_step(_: dict[str, Any]) -> str: CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { "user": SchemaFlowMenuStep(MENU_1), "option1": SchemaFlowFormStep(vol.Schema({}), next_step=_option1_next_step), - "menu2": SchemaFlowMenuStep(MENU_2), + "menu2": SchemaFlowMenuStep(menu_2), "option3": SchemaFlowFormStep(vol.Schema({}), next_step="option4"), "option4": SchemaFlowFormStep(vol.Schema({})), } From 87f0703be17a20f0f87a1d7d76ca0781173555ab Mon Sep 17 00:00:00 2001 From: Tomeroeni <30298350+Tomeroeni@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:20:45 +0200 Subject: [PATCH 20/20] Add support for port control in UniFi switch integration (#150152) --- homeassistant/components/unifi/switch.py | 38 +++- .../unifi/snapshots/test_switch.ambr | 196 ++++++++++++++++++ tests/components/unifi/test_switch.py | 107 ++++++++++ 3 files changed, 340 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 1ca409bec774b3..b9fbf48cf49140 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -27,7 +27,10 @@ from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItem from aiounifi.models.client import Client, ClientBlockRequest -from aiounifi.models.device import DeviceSetOutletRelayRequest +from aiounifi.models.device import ( + DeviceSetOutletRelayRequest, + DeviceSetPortEnabledRequest, +) from aiounifi.models.dpi_restriction_app import DPIRestrictionAppEnableRequest from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup from aiounifi.models.event import Event, EventKey @@ -156,6 +159,14 @@ def async_outlet_switching_supported_fn(hub: UnifiHub, obj_id: str) -> bool: return outlet.has_relay or outlet.caps in (1, 3) +@callback +def async_port_control_supported_fn(hub: UnifiHub, obj_id: str) -> bool: + """Determine if a port supports switching.""" + port = hub.api.ports[obj_id] + # Only allow switching for physical ports that exist + return port.port_idx is not None + + async def async_outlet_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control outlet relay.""" mac, _, index = obj_id.partition("_") @@ -174,6 +185,15 @@ async def async_poe_port_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> hub.queue_poe_port_command(mac, int(index), state) +async def async_port_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: + """Control port enabled state.""" + mac, _, index = obj_id.partition("_") + device = hub.api.devices[mac] + await hub.api.request( + DeviceSetPortEnabledRequest.create(device, int(index), target) + ) + + async def async_port_forward_control_fn( hub: UnifiHub, obj_id: str, target: bool ) -> None: @@ -338,6 +358,22 @@ class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( supported_fn=lambda hub, obj_id: bool(hub.api.ports[obj_id].port_poe), unique_id_fn=lambda hub, obj_id: f"poe-{obj_id}", ), + UnifiSwitchEntityDescription[Ports, Port]( + key="Port control", + translation_key="port_control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + api_handler_fn=lambda api: api.ports, + available_fn=async_device_available_fn, + control_fn=async_port_control_fn, + device_info_fn=async_device_device_info_fn, + is_on_fn=lambda hub, port: bool(port.enabled), + name_fn=lambda port: port.name, + object_fn=lambda api, obj_id: api.ports[obj_id], + supported_fn=async_port_control_supported_fn, + unique_id_fn=lambda hub, obj_id: f"port-{obj_id}", + ), UnifiSwitchEntityDescription[Wlans, Wlan]( key="WLAN control", translation_key="wlan_control", diff --git a/tests/components/unifi/snapshots/test_switch.ambr b/tests/components/unifi/snapshots/test_switch.ambr index 017fe237025225..4fabff5d2783f4 100644 --- a/tests/components/unifi/snapshots/test_switch.ambr +++ b/tests/components/unifi/snapshots/test_switch.ambr @@ -194,6 +194,55 @@ 'state': 'on', }) # --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_name_port_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Port 1', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_control', + 'unique_id': 'port-10:00:00:00:01:01_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'mock-name Port 1', + }), + 'context': , + 'entity_id': 'switch.mock_name_port_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_1_poe-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -243,6 +292,55 @@ 'state': 'on', }) # --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_name_port_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Port 2', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_control', + 'unique_id': 'port-10:00:00:00:01:01_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'mock-name Port 2', + }), + 'context': , + 'entity_id': 'switch.mock_name_port_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_2_poe-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -292,6 +390,104 @@ 'state': 'on', }) # --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_name_port_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Port 3', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_control', + 'unique_id': 'port-10:00:00:00:01:01_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'mock-name Port 3', + }), + 'context': , + 'entity_id': 'switch.mock_name_port_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_name_port_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Port 4', + 'platform': 'unifi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_control', + 'unique_id': 'port-10:00:00:00:01:01_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'mock-name Port 4', + }), + 'context': , + 'entity_id': 'switch.mock_name_port_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entity_and_device_data[site_payload0-wlan_payload0-traffic_rule_payload0-port_forward_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0][switch.mock_name_port_4_poe-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index c14ecbc0b068c6..442bc4f83e6424 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -150,6 +150,7 @@ "portconf_id": "1a1", "port_poe": True, "up": True, + "enable": True, }, { "media": "GE", @@ -164,6 +165,7 @@ "portconf_id": "1a2", "port_poe": True, "up": True, + "enable": True, }, { "media": "GE", @@ -178,6 +180,7 @@ "portconf_id": "1a3", "port_poe": False, "up": True, + "enable": True, }, { "media": "GE", @@ -192,6 +195,7 @@ "portconf_id": "1a4", "port_poe": True, "up": True, + "enable": True, }, ], "state": 1, @@ -1727,6 +1731,7 @@ async def test_port_forwarding_switches( "portconf_id": "1a1", "port_poe": True, "up": True, + "enable": True, }, ], }, @@ -1783,6 +1788,7 @@ async def test_hub_state_change( entity_ids = ( "switch.block_client_2", "switch.mock_name_port_1_poe", + "switch.mock_name_port_1", "switch.plug_outlet_1", "switch.block_media_streaming", "switch.unifi_network_plex", @@ -1802,3 +1808,104 @@ async def test_hub_state_change( await mock_websocket_state.reconnect() for entity_id in entity_ids: assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) +async def test_port_control_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + mock_websocket_message: WebsocketMessageMock, + device_payload: list[dict[str, Any]], +) -> None: + """Test port control entities work.""" + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + ent_reg_entry = entity_registry.async_get("switch.mock_name_port_1") + assert ( + ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + ) # ✅ Disabled by default + + # Enable entity + entity_registry.async_update_entity( + entity_id="switch.mock_name_port_1", disabled_by=None + ) + entity_registry.async_update_entity( + entity_id="switch.mock_name_port_2", disabled_by=None + ) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Validate state object + assert hass.states.get("switch.mock_name_port_1").state == STATE_ON + + # Update state object - disable port via port_overrides + device_1 = deepcopy(device_payload[0]) + device_1["port_table"][0]["enable"] = False + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1").state == STATE_OFF + + # Turn off port + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.mock_name_port_1"}, + blocking=True, + ) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "port_overrides": [{"enable": False, "port_idx": 1, "portconf_id": "1a1"}] + } + + # Turn on port + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.mock_name_port_1"}, + blocking=True, + ) + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.mock_name_port_2"}, + blocking=True, + ) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert aioclient_mock.call_count == 3 + assert aioclient_mock.mock_calls[1][2] == { + "port_overrides": [ + {"port_idx": 1, "enable": True, "portconf_id": "1a1"}, + ] + } + assert aioclient_mock.mock_calls[2][2] == { + "port_overrides": [ + {"port_idx": 2, "enable": False, "portconf_id": "1a2"}, + ] + } + # Device gets disabled + device_1["disabled"] = True + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1").state == STATE_UNAVAILABLE + + # Device gets re-enabled + device_1["disabled"] = False + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1").state == STATE_OFF