From 4e0472feb5e49fc9a7f4e871835817d82efba04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Thu, 7 May 2026 07:33:28 +0200 Subject: [PATCH 1/6] Add fixture for Tuya camera (knkaf1d0dytgyhix) (#169967) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> --- .../tuya/fixtures/sp_knkaf1d0dytgyhix.json | 289 +++++++++++++ .../tuya/snapshots/test_camera.ambr | 56 +++ .../components/tuya/snapshots/test_event.ambr | 60 +++ .../components/tuya/snapshots/test_init.ambr | 31 ++ .../components/tuya/snapshots/test_light.ambr | 59 +++ .../tuya/snapshots/test_select.ambr | 240 +++++++++++ .../components/tuya/snapshots/test_siren.ambr | 51 +++ .../tuya/snapshots/test_switch.ambr | 400 ++++++++++++++++++ 8 files changed, 1186 insertions(+) create mode 100644 tests/components/tuya/fixtures/sp_knkaf1d0dytgyhix.json diff --git a/tests/components/tuya/fixtures/sp_knkaf1d0dytgyhix.json b/tests/components/tuya/fixtures/sp_knkaf1d0dytgyhix.json new file mode 100644 index 00000000000000..273354363c3930 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_knkaf1d0dytgyhix.json @@ -0,0 +1,289 @@ +{ + "name": "Security Camera", + "category": "sp", + "product_id": "knkaf1d0dytgyhix", + "product_name": "Security Camera", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-09-11T13:22:03+00:00", + "create_time": "2025-09-11T13:22:03+00:00", + "update_time": "2025-09-11T13:22:03+00:00", + "function": { + "basic_indicator": { + "type": "Boolean", + "value": "{}" + }, + "basic_flip": { + "type": "Boolean", + "value": "{}" + }, + "basic_osd": { + "type": "Boolean", + "value": "{}" + }, + "basic_private": { + "type": "Boolean", + "value": "{}" + }, + "motion_sensitivity": { + "type": "Enum", + "value": "{\"range\":[\"0\",\"1\",\"2\"]}" + }, + "basic_wdr": { + "type": "Boolean", + "value": "{}" + }, + "basic_nightvision": { + "type": "Enum", + "value": "{\"range\":[\"0\",\"1\",\"2\"]}" + }, + "sd_format": { + "type": "Boolean", + "value": "{}" + }, + "motion_record": { + "type": "Boolean", + "value": "{}" + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": "{}" + }, + "ipc_sharp": { + "type": "Integer", + "value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}" + }, + "motion_switch": { + "type": "Boolean", + "value": "{}" + }, + "record_switch1": { + "type": "Boolean", + "value": "{}" + }, + "decibel_switch": { + "type": "Boolean", + "value": "{}" + }, + "decibel_sensitivity": { + "type": "Enum", + "value": "{\"range\":[\"0\",\"1\"]}" + }, + "record_switch": { + "type": "Boolean", + "value": "{}" + }, + "record_mode": { + "type": "Enum", + "value": "{\"range\":[\"1\",\"2\"]}" + }, + "siren_switch": { + "type": "Boolean", + "value": "{}" + }, + "device_restart": { + "type": "Boolean", + "value": "{}" + }, + "motion_area_switch": { + "type": "Boolean", + "value": "{}" + }, + "motion_area": { + "type": "String", + "value": "{\"maxlen\":255}" + }, + "ipc_contrast": { + "type": "Integer", + "value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}" + }, + "ipc_bright": { + "type": "Integer", + "value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}" + } + }, + "local_strategy": {}, + "status_range": { + "basic_indicator": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "basic_flip": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "basic_osd": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "basic_private": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "motion_sensitivity": { + "type": "Enum", + "value": "{\"range\":[\"0\",\"1\",\"2\"]}", + "report_type": null + }, + "basic_wdr": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "basic_nightvision": { + "type": "Enum", + "value": "{\"range\":[\"0\",\"1\",\"2\"]}", + "report_type": null + }, + "sd_storge": { + "type": "String", + "value": "{\"maxlen\":255}", + "report_type": null + }, + "sd_status": { + "type": "Integer", + "value": "{\"min\":1,\"max\":5,\"scale\":0,\"step\":1}", + "report_type": null + }, + "sd_format": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "motion_record": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "movement_detect_pic": { + "type": "Raw", + "value": "{}", + "report_type": null + }, + "sd_format_state": { + "type": "Integer", + "value": "{\"min\":-20000,\"max\":200000,\"scale\":0,\"step\":1}", + "report_type": null + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "ipc_sharp": { + "type": "Integer", + "value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}", + "report_type": null + }, + "motion_switch": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "record_switch1": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "decibel_switch": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "decibel_sensitivity": { + "type": "Enum", + "value": "{\"range\":[\"0\",\"1\"]}", + "report_type": null + }, + "record_switch": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "record_mode": { + "type": "Enum", + "value": "{\"range\":[\"1\",\"2\"]}", + "report_type": null + }, + "siren_switch": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "device_restart": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "motion_area_switch": { + "type": "Boolean", + "value": "{}", + "report_type": null + }, + "motion_area": { + "type": "String", + "value": "{\"maxlen\":255}", + "report_type": null + }, + "alarm_message": { + "type": "String", + "value": "{}", + "report_type": null + }, + "ipc_contrast": { + "type": "Integer", + "value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}", + "report_type": null + }, + "ipc_bright": { + "type": "Integer", + "value": "{\"unit\":\"%\",\"min\":0,\"max\":100,\"scale\":0,\"step\":1}", + "report_type": null + }, + "initiative_message": { + "type": "Raw", + "value": "{}", + "report_type": null + } + }, + "status": { + "basic_indicator": true, + "basic_flip": false, + "basic_osd": true, + "basic_private": false, + "motion_sensitivity": "1", + "basic_wdr": false, + "basic_nightvision": "1", + "sd_storge": "896|896|0", + "sd_status": 5, + "sd_format": false, + "motion_record": false, + "movement_detect_pic": "**REDACTED**", + "sd_format_state": 0, + "ipc_auto_siren": false, + "ipc_sharp": 50, + "motion_switch": true, + "record_switch1": false, + "decibel_switch": false, + "decibel_sensitivity": "0", + "record_switch": true, + "record_mode": "1", + "siren_switch": false, + "device_restart": true, + "motion_area_switch": false, + "motion_area": "{\"num\":1,\"region0\":{\"x\":0,\"y\":0,\"xlen\":100,\"ylen\":100}}", + "alarm_message": "**REDACTED**", + "ipc_contrast": 50, + "ipc_bright": 50, + "initiative_message": "" + }, + "set_up": true, + "support_local": false, + "quirk": null, + "warnings": null +} diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr index 869d283924f96d..59dbeda27a9b2d 100644 --- a/tests/components/tuya/snapshots/test_camera.ambr +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -441,3 +441,59 @@ 'state': 'idle', }) # --- +# name: test_platform_setup_and_discovery[camera.security_camera-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.security_camera', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 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': , + 'translation_key': None, + 'unique_id': 'tuya.xihygtyd0d1faknkps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.security_camera-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.security_camera?token=1', + 'friendly_name': 'Security Camera', + 'model_name': 'Security Camera', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.security_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr index 1be56199ed165a..225771a88a759d 100644 --- a/tests/components/tuya/snapshots/test_event.ambr +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -806,3 +806,63 @@ 'state': '2023-11-01T12:14:15.000+00:00', }) # --- +# name: test_platform_setup_and_discovery[event.security_camera_doorbell_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'triggered', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.security_camera_doorbell_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell message', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Doorbell message', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_message', + 'unique_id': 'tuya.xihygtyd0d1faknkpsalarm_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[event.security_camera_doorbell_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'doorbell', + 'event_type': 'triggered', + 'event_types': list([ + 'triggered', + ]), + 'friendly_name': 'Security Camera Doorbell message', + 'message': '', + }), + 'context': , + 'entity_id': 'event.security_camera_doorbell_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-11-01T12:14:15.000+00:00', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 99185f6bc74bff..52ec376e8395fb 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -8865,6 +8865,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[xihygtyd0d1faknkps] + 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', + 'xihygtyd0d1faknkps', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Security Camera', + 'model_id': 'knkaf1d0dytgyhix', + 'name': 'Security Camera', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[xms6qowipdvjnkdgqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index d6b5e4f0c27b7f..2504a0502de599 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -3245,6 +3245,65 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.security_camera_indicator_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.security_camera_indicator_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Indicator light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_indicator', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.security_camera_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Security Camera Indicator light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.security_camera_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[light.shop_light_5-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 5db9bdcc14634c..7cf4e5b1be821f 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -5025,6 +5025,246 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[select.security_camera_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.security_camera_motion_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Motion detection sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.xihygtyd0d1faknkpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.security_camera_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.security_camera_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_platform_setup_and_discovery[select.security_camera_night_vision-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.security_camera_night_vision', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Night vision', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night vision', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_nightvision', + 'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_nightvision', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.security_camera_night_vision-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Night vision', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.security_camera_night_vision', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_platform_setup_and_discovery[select.security_camera_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.security_camera_record_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.xihygtyd0d1faknkpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.security_camera_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.security_camera_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_platform_setup_and_discovery[select.security_camera_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.security_camera_sound_detection_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sound detection sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.xihygtyd0d1faknkpsdecibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.security_camera_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.security_camera_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_platform_setup_and_discovery[select.security_light_indicator_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index c984815d0632f1..a55094d06887b8 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -152,6 +152,57 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[siren.security_camera-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.security_camera', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 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': , + 'translation_key': None, + 'unique_id': 'tuya.xihygtyd0d1faknkpssiren_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[siren.security_camera-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.security_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[siren.siren_veranda-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 7bb25523c7b13b..7d88812ae705f7 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -9125,6 +9125,406 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.security_camera_flip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.security_camera_flip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flip', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Flip', + }), + 'context': , + 'entity_id': 'switch.security_camera_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_motion_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.security_camera_motion_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Motion alarm', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.xihygtyd0d1faknkpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Motion alarm', + }), + 'context': , + 'entity_id': 'switch.security_camera_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_motion_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.security_camera_motion_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Motion recording', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_recording', + 'unique_id': 'tuya.xihygtyd0d1faknkpsmotion_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_motion_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Motion recording', + }), + 'context': , + 'entity_id': 'switch.security_camera_motion_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_privacy_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.security_camera_privacy_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Privacy mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode', + 'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_private', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_privacy_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Privacy mode', + }), + 'context': , + 'entity_id': 'switch.security_camera_privacy_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_sound_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.security_camera_sound_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sound detection', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.xihygtyd0d1faknkpsdecibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Sound detection', + }), + 'context': , + 'entity_id': 'switch.security_camera_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_time_watermark-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.security_camera_time_watermark', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Time watermark', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Time watermark', + }), + 'context': , + 'entity_id': 'switch.security_camera_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_video_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.security_camera_video_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Video recording', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.xihygtyd0d1faknkpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Video recording', + }), + 'context': , + 'entity_id': 'switch.security_camera_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_wide_dynamic_range-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.security_camera_wide_dynamic_range', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wide dynamic range', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wide dynamic range', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wide_dynamic_range', + 'unique_id': 'tuya.xihygtyd0d1faknkpsbasic_wdr', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_camera_wide_dynamic_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Camera Wide dynamic range', + }), + 'context': , + 'entity_id': 'switch.security_camera_wide_dynamic_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.security_light_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From e3bcce06bf63d5b535c972ddc5a5c3d2603a173e Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Thu, 7 May 2026 08:30:41 +0200 Subject: [PATCH 2/6] Bump PyViCare to 2.60.2 (#169918) Co-authored-by: home-assistant[bot] <78085893+home-assistant[bot]@users.noreply.github.com> --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index d2ee577f34c27c..10cc32d22264f7 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.60.1"] + "requirements": ["PyViCare==2.60.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e8df893f466aaf..08f4bca4b2363a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -99,7 +99,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.60.1 +PyViCare==2.60.2 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5df0f298bfa0e..d9c7a131c4652f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -96,7 +96,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.60.1 +PyViCare==2.60.2 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 10f1cbb51e24c261dc29f12b8c5bfc16095cd39f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 7 May 2026 09:15:21 +0200 Subject: [PATCH 3/6] Migrate mill to use entry.runtime_data (#169948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/mill/__init__.py | 23 +++++++++----------- homeassistant/components/mill/climate.py | 20 +++++------------ homeassistant/components/mill/coordinator.py | 3 +++ homeassistant/components/mill/number.py | 15 +++++-------- homeassistant/components/mill/sensor.py | 16 ++++---------- tests/components/mill/test_init.py | 4 +++- 6 files changed, 30 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 8349ba2b1d133a..fe07132ff5691a 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -5,30 +5,31 @@ from mill import Mill from mill_local import Mill as MillLocal -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL -from .coordinator import MillDataUpdateCoordinator, MillHistoricDataUpdateCoordinator +from .coordinator import ( + MillConfigEntry, + MillDataUpdateCoordinator, + MillHistoricDataUpdateCoordinator, +) PLATFORMS = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] +__all__ = ["CLOUD", "CONNECTION_TYPE", "DOMAIN", "LOCAL"] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Mill heater.""" - hass.data.setdefault(DOMAIN, {LOCAL: {}, CLOUD: {}}) +async def async_setup_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool: + """Set up the Mill heater.""" if entry.data.get(CONNECTION_TYPE) == LOCAL: mill_data_connection = MillLocal( entry.data[CONF_IP_ADDRESS], websession=async_get_clientsession(hass), ) update_interval = timedelta(seconds=15) - key = entry.data[CONF_IP_ADDRESS] - conn_type = LOCAL else: mill_data_connection = Mill( entry.data[CONF_USERNAME], @@ -36,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession=async_get_clientsession(hass), ) update_interval = timedelta(seconds=30) - key = entry.data[CONF_USERNAME] - conn_type = CLOUD historic_data_coordinator = MillHistoricDataUpdateCoordinator( hass, @@ -56,14 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await data_coordinator.async_config_entry_first_refresh() - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - hass.data[DOMAIN][conn_type][key] = data_coordinator + entry.runtime_data = data_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MillConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 2def1714e8f231..6509ee17d294d6 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -1,5 +1,4 @@ """Support for mill wifi-enabled home heaters.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from typing import Any @@ -14,14 +13,7 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_IP_ADDRESS, - CONF_USERNAME, - PRECISION_TENTHS, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -33,7 +25,6 @@ ATTR_COMFORT_TEMP, ATTR_ROOM_NAME, ATTR_SLEEP_TEMP, - CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL, @@ -42,7 +33,7 @@ MIN_TEMP, SERVICE_SET_ROOM_TEMP, ) -from .coordinator import MillDataUpdateCoordinator +from .coordinator import MillConfigEntry, MillDataUpdateCoordinator from .entity import MillBaseEntity SET_ROOM_TEMP_SCHEMA = vol.Schema( @@ -57,17 +48,16 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MillConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill climate.""" + mill_data_coordinator = entry.runtime_data + if entry.data.get(CONNECTION_TYPE) == LOCAL: - mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]] async_add_entities([LocalMillHeater(mill_data_coordinator)]) return - mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]] - entities = [ MillHeater(mill_data_coordinator, mill_device) for mill_device in mill_data_coordinator.data.values() diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index 231d8520ba0b27..f4b95c0171789e 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -57,6 +57,9 @@ def __init__( ) +type MillConfigEntry = ConfigEntry[MillDataUpdateCoordinator] + + class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Mill historic data.""" diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py index fa1d5363efa697..237abb8cb7b476 100644 --- a/homeassistant/components/mill/number.py +++ b/homeassistant/components/mill/number.py @@ -3,28 +3,23 @@ from mill import Heater, MillDevice from homeassistant.components.number import NumberDeviceClass, NumberEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, UnitOfPower +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CLOUD, CONNECTION_TYPE, DOMAIN -from .coordinator import MillDataUpdateCoordinator +from .const import CLOUD, CONNECTION_TYPE +from .coordinator import MillConfigEntry, MillDataUpdateCoordinator from .entity import MillBaseEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MillConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill Number.""" if entry.data.get(CONNECTION_TYPE) == CLOUD: - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=hass-use-runtime-data - mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][ - entry.data[CONF_USERNAME] - ] + mill_data_coordinator = entry.runtime_data async_add_entities( MillNumber(mill_data_coordinator, mill_device) diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 40384fece01aae..ab221263eaa658 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -1,5 +1,4 @@ """Support for mill wifi-enabled home heaters.""" -# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import mill @@ -9,12 +8,9 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - CONF_IP_ADDRESS, - CONF_USERNAME, PERCENTAGE, EntityCategory, UnitOfEnergy, @@ -29,11 +25,9 @@ from .const import ( BATTERY, - CLOUD, CONNECTION_TYPE, CONSUMPTION_TODAY, CONSUMPTION_YEAR, - DOMAIN, ECO2, HUMIDITY, LOCAL, @@ -41,7 +35,7 @@ TEMPERATURE, TVOC, ) -from .coordinator import MillDataUpdateCoordinator +from .coordinator import MillConfigEntry, MillDataUpdateCoordinator from .entity import MillBaseEntity HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -146,13 +140,13 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MillConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill sensor.""" - if entry.data.get(CONNECTION_TYPE) == LOCAL: - mill_data_coordinator = hass.data[DOMAIN][LOCAL][entry.data[CONF_IP_ADDRESS]] + mill_data_coordinator = entry.runtime_data + if entry.data.get(CONNECTION_TYPE) == LOCAL: async_add_entities( LocalMillSensor( mill_data_coordinator, @@ -162,8 +156,6 @@ async def async_setup_entry( ) return - mill_data_coordinator = hass.data[DOMAIN][CLOUD][entry.data[CONF_USERNAME]] - entities = [ MillSensor( mill_data_coordinator, diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index 97b40d10d182d8..ad781a1d040470 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant.components import mill +from homeassistant.components.mill.coordinator import MillDataUpdateCoordinator from homeassistant.components.recorder import Recorder from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -156,7 +157,8 @@ async def test_unload_entry(recorder_mock: Recorder, hass: HomeAssistant) -> Non ): assert await async_setup_component(hass, "mill", {}) + assert isinstance(entry.runtime_data, MillDataUpdateCoordinator) + assert await hass.config_entries.async_unload(entry.entry_id) assert unload_entry.call_count == 3 - assert entry.entry_id not in hass.data[mill.DOMAIN] From b8ba1c123dd02213db38162a56ff370fabcce2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Farkasdi?= <93778865+farkasdi@users.noreply.github.com> Date: Thu, 7 May 2026 09:18:39 +0200 Subject: [PATCH 4/6] netatmo: add doortag direct category fetch (#169711) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Erik Montnemery --- .../components/netatmo/binary_sensor.py | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index 21fbff3fc72487..3fa581270942bc 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -67,25 +67,11 @@ def get_opening_category(netatmo_device: NetatmoDevice) -> str: - """Helper function to get opening category from Netatmo API raw data.""" - - # Iterate through each home in the raw data. - for home in netatmo_device.data_handler.account.raw_data["homes"]: - # Check if the modules list exists for the current home. - if "modules" in home: - # Iterate through each module to find a matching ID. - for module in home["modules"]: - if module["id"] == netatmo_device.device.entity_id: - # We found the matching device. Get its category. - if module.get("category") is not None: - return cast(str, module["category"]) - raise ValueError( - f"Device {netatmo_device.device.entity_id} found, " - "but 'category' is missing in raw data." - ) + """Helper function to get opening category for doortag.""" - raise ValueError( - f"Device {netatmo_device.device.entity_id} not found in Netatmo raw data." + return ( + getattr(netatmo_device.device, "doortag_category", None) + or DOORTAG_CATEGORY_OTHER ) From c2ce313ec86341aa04cdfc18262ea784a1de6506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 7 May 2026 09:41:08 +0200 Subject: [PATCH 5/6] Bump pyTibber to 0.37.5 (#169981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 00b4120a727efc..9877b62f369a6d 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.37.4"] + "requirements": ["pyTibber==0.37.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08f4bca4b2363a..acac3989ff8523 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1947,7 +1947,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.37.4 +pyTibber==0.37.5 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9c7a131c4652f..64d4d20b0d93e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1690,7 +1690,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.37.4 +pyTibber==0.37.5 # homeassistant.components.dlink pyW215==0.8.0 From 427758ef157db1d6cc1548597a831771e77cd65c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 May 2026 10:30:33 +0200 Subject: [PATCH 6/6] Filter excluded states in entity trigger base class (#169956) --- homeassistant/components/counter/trigger.py | 21 +---- homeassistant/components/cover/trigger.py | 6 +- homeassistant/components/doorbell/trigger.py | 6 +- homeassistant/components/event/trigger.py | 4 +- .../components/media_player/trigger.py | 6 +- homeassistant/components/schedule/trigger.py | 7 +- homeassistant/components/select/trigger.py | 13 +-- homeassistant/components/text/trigger.py | 13 +-- homeassistant/helpers/trigger.py | 86 ++++++++++--------- tests/helpers/test_trigger.py | 8 -- 10 files changed, 57 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/counter/trigger.py b/homeassistant/components/counter/trigger.py index bcb1a23be84234..f84191e1873a0c 100644 --- a/homeassistant/components/counter/trigger.py +++ b/homeassistant/components/counter/trigger.py @@ -1,11 +1,6 @@ """Provides triggers for counters.""" -from homeassistant.const import ( - CONF_MAXIMUM, - CONF_MINIMUM, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -41,9 +36,7 @@ class CounterDecrementedTrigger(CounterBaseIntegerTrigger): """Trigger for when a counter is decremented.""" def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False + """Check that the counter value decreased.""" return int(from_state.state) > int(to_state.state) @@ -51,9 +44,7 @@ class CounterIncrementedTrigger(CounterBaseIntegerTrigger): """Trigger for when a counter is incremented.""" def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False + """Check that the counter value increased.""" return int(from_state.state) < int(to_state.state) @@ -62,12 +53,6 @@ class CounterValueBaseTrigger(EntityTriggerBase): _domain_specs = {DOMAIN: DomainSpec()} - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - return from_state.state != to_state.state - class CounterMaxReachedTrigger(CounterValueBaseTrigger): """Trigger for when a counter reaches its maximum value.""" diff --git a/homeassistant/components/cover/trigger.py b/homeassistant/components/cover/trigger.py index 1d3ceba1177731..c9f81c9d6d14cd 100644 --- a/homeassistant/components/cover/trigger.py +++ b/homeassistant/components/cover/trigger.py @@ -2,7 +2,7 @@ from collections.abc import Mapping -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.trigger import EntityTriggerBase, Trigger @@ -28,9 +28,7 @@ def is_valid_state(self, state: State) -> bool: return self._get_value(state) == domain_spec.target_value def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the transition is valid for a cover state change.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False + """Check that the relevant cover value changed.""" if (from_value := self._get_value(from_state)) is None: return False return from_value != self._get_value(to_state) diff --git a/homeassistant/components/doorbell/trigger.py b/homeassistant/components/doorbell/trigger.py index 420d3b244d526a..e111a92c78c77d 100644 --- a/homeassistant/components/doorbell/trigger.py +++ b/homeassistant/components/doorbell/trigger.py @@ -17,10 +17,8 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase): _domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)} def is_valid_state(self, state: State) -> bool: - """Check if the entity is available and the event type is ring.""" - return super().is_valid_state(state) and ( - state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING - ) + """Check if the event type is ring.""" + return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/event/trigger.py b/homeassistant/components/event/trigger.py index 8f8c05862a96c4..81739d242887e7 100644 --- a/homeassistant/components/event/trigger.py +++ b/homeassistant/components/event/trigger.py @@ -41,9 +41,7 @@ def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: def is_valid_state(self, state: State) -> bool: """Check if the event type matches one of the configured types.""" - return super().is_valid_state(state) and ( - state.attributes.get(ATTR_EVENT_TYPE) in self._event_types - ) + return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 9bdb20460d031f..25d2c540eb8c7f 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -1,6 +1,5 @@ """Provides triggers for media players.""" -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -50,10 +49,7 @@ def is_muted(self, state: State) -> bool: ) def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - + """Check that the muted-state changed.""" if not self._has_volume_attributes(to_state): return False diff --git a/homeassistant/components/schedule/trigger.py b/homeassistant/components/schedule/trigger.py index fb49e963a31398..bb7a910bd6f6a0 100644 --- a/homeassistant/components/schedule/trigger.py +++ b/homeassistant/components/schedule/trigger.py @@ -1,6 +1,6 @@ """Provides triggers for schedules.""" -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -20,10 +20,7 @@ class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): _to_states = {STATE_ON} def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state matches the expected ones.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - + """Check that the origin matches and the next event changed.""" from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT) to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT) diff --git a/homeassistant/components/select/trigger.py b/homeassistant/components/select/trigger.py index d33f0656c104e2..db2bd0a6de2624 100644 --- a/homeassistant/components/select/trigger.py +++ b/homeassistant/components/select/trigger.py @@ -1,8 +1,7 @@ """Provides triggers for selects.""" from homeassistant.components.input_select import DOMAIN as INPUT_SELECT_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA, @@ -19,16 +18,6 @@ class SelectionChangedTrigger(EntityTriggerBase): _domain_specs = {DOMAIN: DomainSpec(), INPUT_SELECT_DOMAIN: DomainSpec()} _schema = ENTITY_STATE_TRIGGER_SCHEMA - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - return from_state.state != to_state.state - - def is_valid_state(self, state: State) -> bool: - """Check if the new state is not invalid.""" - return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - TRIGGERS: dict[str, type[Trigger]] = { "selection_changed": SelectionChangedTrigger, diff --git a/homeassistant/components/text/trigger.py b/homeassistant/components/text/trigger.py index af2480bf888d45..b92f5fa97ad052 100644 --- a/homeassistant/components/text/trigger.py +++ b/homeassistant/components/text/trigger.py @@ -1,8 +1,7 @@ """Provides triggers for text and input_text entities.""" from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( ENTITY_STATE_TRIGGER_SCHEMA, @@ -19,16 +18,6 @@ class TextChangedTrigger(EntityTriggerBase): _domain_specs = {DOMAIN: DomainSpec(), INPUT_TEXT_DOMAIN: DomainSpec()} _schema = ENTITY_STATE_TRIGGER_SCHEMA - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - return from_state.state != to_state.state - - def is_valid_state(self, state: State) -> bool: - """Check if the new state is not invalid.""" - return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - TRIGGERS: dict[str, type[Trigger]] = { "changed": TextChangedTrigger, diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index af9c700aa1d4ba..9d052de3c86370 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -353,9 +353,14 @@ class EntityTriggerBase(Trigger): """Trigger for entity state changes.""" _domain_specs: Mapping[str, DomainSpec] + # States filtered from the to_state pre-filter (and `_should_include`). _excluded_states: Final[frozenset[str]] = frozenset( {STATE_UNAVAILABLE, STATE_UNKNOWN} ) + # States filtered from the from_state pre-filter. Defaults to + # `_excluded_states`. Subclasses can override to relax the origin + # check. + _excluded_from_states: ClassVar[frozenset[str]] = _excluded_states _schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST # When True, indirect target expansion (via device/area/floor) skips # entities with an entity_category. @@ -389,13 +394,28 @@ def _get_tracked_value(self, state: State) -> Any: return state.state return state.attributes.get(domain_spec.value_source) - @abc.abstractmethod def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" + """Check if the transition should fire the trigger. + + Called only after `from_state.state` has been filtered against + `_excluded_from_states` and `to_state.state` against + `_excluded_states`, so subclasses don't need to repeat those + checks. Default: any state change. Override to add semantics + (specific from/to states, value changed across a threshold, + etc.). + """ + return from_state.state != to_state.state - @abc.abstractmethod def is_valid_state(self, state: State) -> bool: - """Check if the new state matches the expected state(s).""" + """Check if the state is a target state for the trigger. + + Called only after `state.state` has been filtered against + `_excluded_states`, so subclasses don't need to repeat that + check. Default: any non-excluded state is a target. Override + to restrict (specific to_states, value within a threshold, + etc.). + """ + return True def _should_include(self, state: State) -> bool: """Check if an entity should participate in all/count checks. @@ -473,19 +493,26 @@ def state_still_valid( ) return matches >= 1 # Behavior any: check the individual entity's state - if not to_state: + if not to_state or to_state.state in self._excluded_states: return False return self.is_valid_state(to_state) if not from_state or not to_state: return - # The trigger should never fire if the new state is not valid - if not self.is_valid_state(to_state): + # The trigger should never fire if the new state is excluded + # or not a target state. + if to_state.state in self._excluded_states or not self.is_valid_state( + to_state + ): return - # The trigger should never fire if the transition is not valid - if not self.is_valid_transition(from_state, to_state): + # The trigger should never fire if the origin state is excluded + # or the transition is not valid. + if ( + from_state.state in self._excluded_from_states + or not self.is_valid_transition(from_state, to_state) + ): return if behavior == BEHAVIOR_LAST: @@ -570,10 +597,7 @@ class EntityTargetStateTriggerBase(EntityTriggerBase): _to_states: set[str] def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - + """Check the value changed and the origin was not already a target state.""" from_value = self._get_tracked_value(from_state) return ( from_value != self._get_tracked_value(to_state) @@ -593,9 +617,6 @@ class EntityTransitionTriggerBase(EntityTriggerBase): def is_valid_transition(self, from_state: State, to_state: State) -> bool: """Check if the origin state matches the expected ones.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - from_value = self._get_tracked_value(from_state) return ( from_value != self._get_tracked_value(to_state) @@ -620,10 +641,8 @@ def is_valid_transition(self, from_state: State, to_state: State) -> bool: ) def is_valid_state(self, state: State) -> bool: - """Check if the new state is valid.""" - return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) and bool( - self._get_tracked_value(state) != self._from_state - ) + """Check that the new state is different from the origin state.""" + return bool(self._get_tracked_value(state) != self._from_state) class StatelessEntityTriggerBase(EntityTriggerBase): @@ -631,23 +650,12 @@ class StatelessEntityTriggerBase(EntityTriggerBase): Used for stateless entities (buttons, scenes, doorbells, events) whose `state.state` is just a timestamp of the last activation. + `STATE_UNKNOWN` is a legitimate prior state — the first activation + after startup must still fire the trigger. """ _schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA - - def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is available and the state has changed. - - STATE_UNKNOWN is allowed as the origin state so the first - activation fires. - """ - if from_state.state == STATE_UNAVAILABLE: - return False - return from_state.state != to_state.state - - def is_valid_state(self, state: State) -> bool: - """Check that the entity has been activated at least once.""" - return state.state not in self._excluded_states + _excluded_from_states: ClassVar[frozenset[str]] = frozenset({STATE_UNAVAILABLE}) NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend( @@ -826,10 +834,7 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase): _schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - + """Check if the tracked numeric value has changed.""" return self._get_tracked_value(from_state) != self._get_tracked_value(to_state) @@ -888,10 +893,7 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge _schema = NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA def is_valid_transition(self, from_state: State, to_state: State) -> bool: - """Check if the origin state is valid and the state has changed.""" - if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False - + """Check that the tracked value crossed into the threshold range.""" return not self.is_valid_state(from_state) diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 25e4be1c4984dc..a9fdf124d5fc20 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -2969,10 +2969,6 @@ async def test_make_entity_target_state_trigger( # Value did not change — not a valid transition assert not trig.is_valid_transition(from_state, from_state) - # From unavailable — not valid - unavailable = State("light.bed", STATE_UNAVAILABLE, {}) - assert not trig.is_valid_transition(unavailable, to_state) - # Value not in to_states — not valid assert not trig.is_valid_state(wrong_value_state) @@ -3043,10 +3039,6 @@ async def test_make_entity_transition_trigger( # No change in tracked value — not a valid transition assert not trig.is_valid_transition(from_state, from_state) - # From unavailable — not valid - unavailable = State("climate.living", STATE_UNAVAILABLE, {}) - assert not trig.is_valid_transition(unavailable, to_state) - @pytest.mark.parametrize( ("domain_specs", "origin", "from_state", "to_state", "wrong_from"),