From fac5b2c09cac9fffc6dc682539124e0c22f05041 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:58:46 +0200 Subject: [PATCH 01/36] Add Tuya snapshots tests for camera platform (#149959) --- tests/components/tuya/__init__.py | 11 + .../tuya/fixtures/sp_sdd5f5f2dl5wydjf.json | 383 ++++++++++++++++++ .../tuya/snapshots/test_camera.ambr | 162 ++++++++ .../tuya/snapshots/test_number.ambr | 58 +++ .../tuya/snapshots/test_select.ambr | 173 ++++++++ .../tuya/snapshots/test_sensor.ambr | 53 +++ .../components/tuya/snapshots/test_siren.ambr | 49 +++ .../tuya/snapshots/test_switch.ambr | 336 +++++++++++++++ tests/components/tuya/test_camera.py | 73 ++++ 9 files changed, 1298 insertions(+) create mode 100644 tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json create mode 100644 tests/components/tuya/snapshots/test_camera.ambr create mode 100644 tests/components/tuya/test_camera.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 7d6cd32959c..05d636b8393 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -306,16 +306,27 @@ DEVICE_MOCKS = { ], "sp_drezasavompxpcgm": [ # https://github.com/home-assistant/core/issues/149704 + Platform.CAMERA, Platform.LIGHT, Platform.SELECT, Platform.SWITCH, ], "sp_rjKXWRohlvOTyLBu": [ # https://github.com/home-assistant/core/issues/149704 + Platform.CAMERA, Platform.LIGHT, Platform.SELECT, Platform.SWITCH, ], + "sp_sdd5f5f2dl5wydjf": [ + # https://github.com/home-assistant/core/issues/144087 + Platform.CAMERA, + Platform.NUMBER, + Platform.SENSOR, + Platform.SELECT, + Platform.SIREN, + Platform.SWITCH, + ], "tdq_cq1p0nt0a4rixnex": [ # https://github.com/home-assistant/core/issues/146845 Platform.SELECT, diff --git a/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json new file mode 100644 index 00000000000..7e4705650b1 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json @@ -0,0 +1,383 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf3f8b448bbc123e29oghf", + "name": "C9", + "category": "sp", + "product_id": "sdd5f5f2dl5wydjf", + "product_name": "Security Camera", + "online": true, + "sub": false, + "time_zone": "+11:00", + "active_time": "2025-03-13T07:28:30+00:00", + "create_time": "2025-03-13T07:28:30+00:00", + "update_time": "2025-03-13T07:28:30+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_wdr": { + "type": "Boolean", + "value": {} + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["auto", "ir_mode", "color_mode"] + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "min": 10, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "device_restart": { + "type": "Boolean", + "value": {} + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "cruise_switch": { + "type": "Boolean", + "value": {} + }, + "cruise_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_wdr": { + "type": "Boolean", + "value": {} + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "min": 1, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "min": -20000, + "max": 200000, + "scale": 0, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["auto", "ir_mode", "color_mode"] + } + }, + "battery_report_cap": { + "type": "Integer", + "value": { + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "doorbell_active": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "wireless_electricity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "wireless_powermode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "min": 10, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "doorbell_pic": { + "type": "Raw", + "value": {} + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "device_restart": { + "type": "Boolean", + "value": {} + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "cruise_switch": { + "type": "Boolean", + "value": {} + }, + "cruise_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "alarm_message": { + "type": "String", + "value": {} + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "initiative_message": { + "type": "Raw", + "value": {} + } + }, + "status": { + "basic_flip": false, + "basic_osd": true, + "motion_sensitivity": 1, + "basic_wdr": false, + "sd_storge": "30932992|3407872|27525120", + "sd_status": 1, + "sd_format": false, + "motion_record": false, + "movement_detect_pic": "**REDACTED**", + "ptz_stop": true, + "sd_format_state": 0, + "ptz_control": 5, + "ipc_auto_siren": false, + "nightvision_mode": "auto", + "battery_report_cap": 1, + "ptz_calibration": false, + "motion_switch": true, + "doorbell_active": "", + "wireless_electricity": 80, + "wireless_powermode": 0, + "wireless_lowpower": 10, + "wireless_awake": false, + "record_switch": true, + "record_mode": 1, + "pir_switch": 2, + "doorbell_pic": "", + "siren_switch": false, + "basic_device_volume": 1, + "motion_tracking": true, + "device_restart": false, + "humanoid_filter": true, + "cruise_switch": false, + "cruise_mode": 0, + "alarm_message": "**REDACTED**", + "ipc_work_mode": 0, + "initiative_message": "" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr new file mode 100644 index 00000000000..e1945f03d3c --- /dev/null +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -0,0 +1,162 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][camera.cam_garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.cam_garage', + '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': , + 'translation_key': None, + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][camera.cam_garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_garage?token=1', + 'friendly_name': 'CAM GARAGE', + 'model_name': 'Indoor camera ', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][camera.cam_porch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.cam_porch', + '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': , + 'translation_key': None, + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][camera.cam_porch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_porch?token=1', + 'friendly_name': 'CAM PORCH', + 'model_name': 'Indoor cam Pan/Tilt ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_porch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][camera.c9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.c9', + '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': , + 'translation_key': None, + 'unique_id': 'tuya.bf3f8b448bbc123e29oghf', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][camera.c9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.c9?token=1', + 'friendly_name': 'C9', + 'model_name': 'Security Camera', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.c9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 9a04b9dd78c..fa9d7358314 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -527,6 +527,64 @@ 'state': '10.0', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][number.c9_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.c9_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_device_volume', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][number.c9_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Volume', + 'max': 10.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.c9_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 943e230b7cd..84af76355d5 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -945,6 +945,179 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_ipc_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.c9_ipc_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IPC mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ipc_work_mode', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfipc_work_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_ipc_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 IPC mode', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.c9_ipc_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.c9_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.c9_record_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index e8b9900185e..d2cd0eb0676 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2556,6 +2556,59 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_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.c9_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.bf3f8b448bbc123e29oghfwireless_electricity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'C9 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.c9_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- # name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index 5c46c2bbd19..876db171c7b 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -97,3 +97,52 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][siren.c9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.c9', + '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': , + 'translation_key': None, + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfsiren_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][siren.c9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.c9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1b90c21bb46..aa80ac08ee5 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1934,6 +1934,342 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_flip-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.c9_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Flip', + }), + 'context': , + 'entity_id': 'switch.c9_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion alarm', + }), + 'context': , + 'entity_id': 'switch.c9_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_recording-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.c9_motion_recording', + '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': 'Motion recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_recording', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion recording', + }), + 'context': , + 'entity_id': 'switch.c9_motion_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_tracking-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.c9_motion_tracking', + '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': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion tracking', + }), + 'context': , + 'entity_id': 'switch.c9_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_time_watermark-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.c9_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Time watermark', + }), + 'context': , + 'entity_id': 'switch.c9_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_video_recording-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.c9_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Video recording', + }), + 'context': , + 'entity_id': 'switch.c9_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_wide_dynamic_range-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.c9_wide_dynamic_range', + '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': 'Wide dynamic range', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wide_dynamic_range', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_wdr', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_wide_dynamic_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Wide dynamic range', + }), + 'context': , + 'entity_id': 'switch.c9_wide_dynamic_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py new file mode 100644 index 00000000000..25bfe57ea0c --- /dev/null +++ b/tests/components/tuya/test_camera.py @@ -0,0 +1,73 @@ +"""Test Tuya camera platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock camera access token which normally is randomized.""" + with patch( + "homeassistant.components.camera.SystemRandom.getrandbits", + return_value=1, + ): + yield + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.CAMERA in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.CAMERA not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From 31e647b5b004fa42e32aedec6391f85185b046b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 4 Aug 2025 15:59:07 +0100 Subject: [PATCH 02/36] Bump hass-nabucasa from 0.110.1 to 0.111.0 (#149977) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 63eae6261d4..0ef407b3628 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.110.1"], + "requirements": ["hass-nabucasa==0.111.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f8a57ba61bb..bca5e4648af 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250731.0 diff --git a/pyproject.toml b/pyproject.toml index a32e9308fe2..99ea68be900 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.110.1", + "hass-nabucasa==0.111.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index ba08a72e324..90953842e20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1c7280547c7..6e93cb6c595 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4aa7a5063e6..945c16d3250 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -994,7 +994,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 73ca6b490043f7351ef9d256b1bdd96a7eb60b26 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 4 Aug 2025 11:40:11 -0400 Subject: [PATCH 03/36] Add translation strings for unsupported OS version (#149837) --- homeassistant/components/hassio/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 1e312ee34d9..2b87a7632a0 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -226,6 +226,10 @@ "unsupported_virtualization_image": { "title": "Unsupported system - Incorrect OS image for virtualization", "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." + }, + "unsupported_os_version": { + "title": "Unsupported system - Home Assistant OS version", + "description": "System is unsupported because the Home Assistant OS version in use is not supported. Use the link to learn more and how to fix this." } }, "entity": { From 94f2118b19941c064ced5d2852027115b82a7e99 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Aug 2025 20:34:07 +0200 Subject: [PATCH 04/36] Fix flaky history_stats test case (#149974) --- tests/components/history_stats/test_config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/history_stats/test_config_flow.py b/tests/components/history_stats/test_config_flow.py index a1f0a080b8a..08dbefe7465 100644 --- a/tests/components/history_stats/test_config_flow.py +++ b/tests/components/history_stats/test_config_flow.py @@ -400,10 +400,10 @@ async def test_options_flow_preview( msg = await client.receive_json() assert msg["event"]["state"] == exp_count - hass.states.async_set(monitored_entity, "on") + hass.states.async_set(monitored_entity, "on") - msg = await client.receive_json() - assert msg["event"]["state"] == "3" + msg = await client.receive_json() + assert msg["event"]["state"] == "3" async def test_options_flow_preview_errors( From a9621ac81194f4c4ea99d7938b8c2f4d3e3d2ff8 Mon Sep 17 00:00:00 2001 From: markhannon Date: Tue, 5 Aug 2025 04:41:05 +1000 Subject: [PATCH 05/36] Add tests for Zimi entitites (#144292) --- tests/components/zimi/common.py | 81 ++++++++++++ tests/components/zimi/conftest.py | 28 +++++ .../components/zimi/snapshots/test_cover.ambr | 17 +++ tests/components/zimi/snapshots/test_fan.ambr | 19 +++ .../components/zimi/snapshots/test_light.ambr | 38 ++++++ .../zimi/snapshots/test_switch.ambr | 14 +++ tests/components/zimi/test_cover.py | 77 ++++++++++++ tests/components/zimi/test_fan.py | 75 +++++++++++ tests/components/zimi/test_light.py | 119 ++++++++++++++++++ tests/components/zimi/test_switch.py | 60 +++++++++ 10 files changed, 528 insertions(+) create mode 100644 tests/components/zimi/common.py create mode 100644 tests/components/zimi/conftest.py create mode 100644 tests/components/zimi/snapshots/test_cover.ambr create mode 100644 tests/components/zimi/snapshots/test_fan.ambr create mode 100644 tests/components/zimi/snapshots/test_light.ambr create mode 100644 tests/components/zimi/snapshots/test_switch.ambr create mode 100644 tests/components/zimi/test_cover.py create mode 100644 tests/components/zimi/test_fan.py create mode 100644 tests/components/zimi/test_light.py create mode 100644 tests/components/zimi/test_switch.py diff --git a/tests/components/zimi/common.py b/tests/components/zimi/common.py new file mode 100644 index 00000000000..13582b3d42c --- /dev/null +++ b/tests/components/zimi/common.py @@ -0,0 +1,81 @@ +"""Common items for testing the zimi component.""" + +from unittest.mock import MagicMock, create_autospec, patch + +from zcc.device import ControlPointDevice + +from homeassistant.components.zimi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +DEVICE_INFO = { + "id": "test-device-id", + "name": "unknown", + "manufacturer": "Zimi", + "model": "Controller XYZ", + "hwVersion": "2.2.2", + "fwVersion": "3.3.3", +} + +ENTITY_INFO = { + "id": "test-entity-id", + "name": "Test Entity Name", + "room": "Test Entity Room", + "type": "unknown", +} + +INPUT_HOST = "192.168.1.100" +INPUT_PORT = 5003 + + +def mock_api_device( + device_name: str | None = None, + entity_type: 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.room = ENTITY_INFO["room"] + mock_api_device.name = ENTITY_INFO["name"] + mock_api_device.type = entity_type or ENTITY_INFO["type"] + + mock_manfacture_info = MagicMock() + mock_manfacture_info.identifier = DEVICE_INFO["id"] + mock_manfacture_info.manufacturer = DEVICE_INFO["manufacturer"] + mock_manfacture_info.model = DEVICE_INFO["model"] + mock_manfacture_info.name = device_name or DEVICE_INFO["name"] + mock_manfacture_info.hwVersion = DEVICE_INFO["hwVersion"] + mock_manfacture_info.firmwareVersion = DEVICE_INFO["fwVersion"] + + mock_api_device.manufacture_info = mock_manfacture_info + + mock_api_device.brightness = 0 + mock_api_device.percentage = 0 + + return mock_api_device + + +async def setup_platform( + hass: HomeAssistant, + platform: str, +) -> MockConfigEntry: + """Set up the specified Zimi platform.""" + + if not platform: + raise ValueError("Platform must be specified") + + mock_config = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: INPUT_HOST, CONF_PORT: INPUT_PORT} + ) + mock_config.add_to_hass(hass) + + with patch("homeassistant.components.zimi.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return mock_config diff --git a/tests/components/zimi/conftest.py b/tests/components/zimi/conftest.py new file mode 100644 index 00000000000..44d898deffb --- /dev/null +++ b/tests/components/zimi/conftest.py @@ -0,0 +1,28 @@ +"""Test fixtures for Zimi component.""" + +from unittest.mock import patch + +import pytest + +INPUT_MAC = "aa:bb:cc:dd:ee:ff" + + +API_INFO = { + "brand": "Zimi", + "network_name": "Test Network", + "firmware_version": "1.1.1", +} + + +@pytest.fixture +def mock_api(): + """Mock the API with defaults.""" + with patch("homeassistant.components.zimi.async_connect_to_controller") as mock: + mock_api = mock.return_value + mock_api.connect.return_value = True + mock_api.mac = INPUT_MAC + mock_api.brand = API_INFO["brand"] + mock_api.network_name = API_INFO["network_name"] + mock_api.firmware_version = API_INFO["firmware_version"] + + yield mock_api diff --git a/tests/components/zimi/snapshots/test_cover.ambr b/tests/components/zimi/snapshots/test_cover.ambr new file mode 100644 index 00000000000..66d74f36771 --- /dev/null +++ b/tests/components/zimi/snapshots/test_cover.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_cover_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'garage', + 'friendly_name': 'Cover Controller Test Entity Name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'opening', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_fan.ambr b/tests/components/zimi/snapshots/test_fan.ambr new file mode 100644 index 00000000000..6b3f226b4f9 --- /dev/null +++ b/tests/components/zimi/snapshots/test_fan.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_fan_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fan Controller Test Entity Name', + 'percentage': 1, + 'percentage_step': 12.5, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fan_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_light.ambr b/tests/components/zimi/snapshots/test_light.ambr new file mode 100644 index 00000000000..372e2c937ca --- /dev/null +++ b/tests/components/zimi/snapshots/test_light.ambr @@ -0,0 +1,38 @@ +# serializer version: 1 +# name: test_dimmer_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 0, + 'color_mode': , + 'friendly_name': 'Light Controller Test Entity Name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Light Controller Test Entity Name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_switch.ambr b/tests/components/zimi/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c96fc99b908 --- /dev/null +++ b/tests/components/zimi/snapshots/test_switch.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_switch_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch Controller Test Entity Name', + }), + 'context': , + 'entity_id': 'switch.switch_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/test_cover.py b/tests/components/zimi/test_cover.py new file mode 100644 index 00000000000..68809af49e6 --- /dev/null +++ b/tests/components/zimi/test_cover.py @@ -0,0 +1,77 @@ +"""Test the Zimi cover entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_cover_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests cover entity.""" + + device_name = "Cover Controller" + entity_key = "cover.cover_controller_test_entity_name" + entity_type = Platform.COVER + + mock_api.doors = [mock_api_device(device_name=device_name, entity_type=entity_type)] + + await setup_platform(hass, entity_type) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert ( + entity.supported_features + == CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_CLOSE_COVER in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_CLOSE_COVER, + {"entity_id": entity_key}, + blocking=True, + ) + assert mock_api.doors[0].close_door.called + + assert SERVICE_OPEN_COVER in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_OPEN_COVER, + {"entity_id": entity_key}, + blocking=True, + ) + assert mock_api.doors[0].open_door.called + + assert SERVICE_SET_COVER_POSITION in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_key, "position": 50}, + blocking=True, + ) + assert mock_api.doors[0].open_to_percentage.called diff --git a/tests/components/zimi/test_fan.py b/tests/components/zimi/test_fan.py new file mode 100644 index 00000000000..ed87b32a61f --- /dev/null +++ b/tests/components/zimi/test_fan.py @@ -0,0 +1,75 @@ +"""Test the Zimi fan entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import FanEntityFeature +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_fan_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests fan entity.""" + + device_name = "Fan Controller" + entity_key = "fan.fan_controller_test_entity_name" + entity_type = Platform.FAN + + mock_api.fans = [mock_api_device(device_name=device_name, entity_type=entity_type)] + + await setup_platform(hass, entity_type) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert ( + entity.supported_features + == FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.fans[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.fans[0].turn_off.called + + assert "set_percentage" in services[entity_type] + await hass.services.async_call( + entity_type, + "set_percentage", + {"entity_id": entity_key, "percentage": 50}, + blocking=True, + ) + assert mock_api.fans[0].set_fanspeed.called diff --git a/tests/components/zimi/test_light.py b/tests/components/zimi/test_light.py new file mode 100644 index 00000000000..7716a6368fe --- /dev/null +++ b/tests/components/zimi/test_light.py @@ -0,0 +1,119 @@ +"""Test the Zimi light entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ColorMode +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_light_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests lights entity.""" + + device_name = "Light Controller" + entity_key = "light.light_controller_test_entity_name" + entity_type = "light" + + mock_api.lights = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.LIGHT) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert entity.capabilities == { + "supported_color_modes": [ColorMode.ONOFF], + } + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].turn_off.called + + +async def test_dimmer_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests dimmer entity.""" + + device_name = "Light Controller" + entity_key = "light.light_controller_test_entity_name" + entity_type = "dimmer" + entity_type_override = "light" + + mock_api.lights = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.LIGHT) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert entity.capabilities == { + "supported_color_modes": [ColorMode.BRIGHTNESS], + } + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type_override] + + await hass.services.async_call( + entity_type_override, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].set_brightness.called + + assert SERVICE_TURN_OFF in services[entity_type_override] + + await hass.services.async_call( + entity_type_override, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].set_brightness.called diff --git a/tests/components/zimi/test_switch.py b/tests/components/zimi/test_switch.py new file mode 100644 index 00000000000..2464757e7b6 --- /dev/null +++ b/tests/components/zimi/test_switch.py @@ -0,0 +1,60 @@ +"""Test the Zimi switch entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_switch_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests switch entity.""" + + device_name = "Switch Controller" + entity_key = "switch.switch_controller_test_entity_name" + entity_type = "switch" + + mock_api.outlets = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.SWITCH) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.outlets[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.outlets[0].turn_off.called From 1fbce01e26d6e7db5163e94bdfaf0577f7ea9255 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 21:30:43 +0200 Subject: [PATCH 06/36] Add initial support for Tuya wg2 category (#149676) --- .../components/tuya/binary_sensor.py | 10 +++ tests/components/tuya/__init__.py | 4 + .../tuya/fixtures/wg2_nwxr8qcu4seltoro.json | 90 +++++++++++++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 ++++++++++ 4 files changed, 153 insertions(+) create mode 100644 tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 4fef11a7335..fd3f0cfcb7e 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -314,6 +314,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Zigbee gateway + # Undocumented + "wg2": ( + TuyaBinarySensorEntityDescription( + key=DPCode.MASTER_STATE, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + on_value="alarm", + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 05d636b8393..a66bd314185 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -346,6 +346,10 @@ DEVICE_MOCKS = { Platform.CLIMATE, Platform.SWITCH, ], + "wg2_nwxr8qcu4seltoro": [ + # https://github.com/orgs/home-assistant/discussions/430 + Platform.BINARY_SENSOR, + ], "wk_fi6dne5tu4t1nm6j": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, diff --git a/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json new file mode 100644 index 00000000000..0e39f713dd0 --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json @@ -0,0 +1,90 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1752690839034sq255y", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf79ca977d67322eb2o68m", + "name": "X5 Zigbee Gateway", + "category": "wg2", + "product_id": "nwxr8qcu4seltoro", + "product_name": "X5", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-14T10:19:21+00:00", + "create_time": "2025-07-14T10:19:21+00:00", + "update_time": "2025-07-14T10:19:21+00:00", + "function": { + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "master_language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "french", + "italian", + "german", + "spanish", + "portuguese", + "russian", + "japanese" + ] + } + } + }, + "status_range": { + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "master_information": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "master_language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "french", + "italian", + "german", + "spanish", + "portuguese", + "russian", + "japanese" + ] + } + } + }, + "status": { + "master_state": "normal", + "master_information": "", + "factory_reset": false, + "master_language": "chinese_simplified" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 7cb613ebbf2..6ae0b4997dd 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -685,6 +685,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-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.x5_zigbee_gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf79ca977d67322eb2o68mmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'X5 Zigbee Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.x5_zigbee_gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][binary_sensor.smoke_detector_upstairs_smoke-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4d53450cbfb5b4e8f1f8326cc76971388be778e9 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 4 Aug 2025 15:54:50 -0400 Subject: [PATCH 07/36] Create battery_level deprecation repair for template vacuum platform (#149987) Co-authored-by: Norbert Rittel --- .../components/template/strings.json | 6 +++ homeassistant/components/template/vacuum.py | 47 ++++++++++++++++++- tests/components/template/test_vacuum.py | 33 ++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index be5fb1866ea..96c8435c25c 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -440,6 +440,12 @@ } } }, + "issues": { + "deprecated_battery_level": { + "title": "Deprecated battery level option in {entity_name}", + "description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name})." + } + }, "options": { "step": { "alarm_control_panel": { diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1abfdbd00da..242a534187a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -34,11 +34,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + template, +) from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -188,6 +193,26 @@ def async_create_preview_vacuum( ) +def create_issue( + hass: HomeAssistant, supported_features: int, name: str, entity_id: str +) -> None: + """Create the battery_level issue.""" + if supported_features & VacuumEntityFeature.BATTERY: + key = "deprecated_battery_level" + ir.async_create_issue( + hass, + DOMAIN, + f"{key}_{entity_id}", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=key, + translation_placeholders={ + "entity_name": name, + "entity_id": entity_id, + }, + ) + + class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" @@ -369,6 +394,16 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + create_issue( + self.hass, + self._attr_supported_features, + self._attr_name or DEFAULT_NAME, + self.entity_id, + ) + @callback def _async_setup_templates(self) -> None: """Set up templates.""" @@ -434,6 +469,16 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): self._to_render_simple.append(key) self._parse_result.add(key) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + create_issue( + self.hass, + self._attr_supported_features, + self._attr_name or DEFAULT_NAME, + self.entity_id, + ) + @callback def _handle_coordinator_update(self) -> None: """Handle update of the data.""" diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6c7222645b6..d0e6488e46e 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -15,7 +15,7 @@ from homeassistant.components.vacuum import ( from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component @@ -589,6 +589,37 @@ async def test_battery_level_template( _verify(hass, STATE_UNKNOWN, expected) +@pytest.mark.parametrize( + ("count", "state_template", "extra_config", "attribute_template"), + [(1, "{{ states('sensor.test_state') }}", {}, "{{ 50 }}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "battery_level_template"), + (ConfigurationStyle.MODERN, "battery_level"), + (ConfigurationStyle.TRIGGER, "battery_level"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_battery_level_template_repair( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test battery_level template raises issue.""" + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + "template", f"deprecated_battery_level_{TEST_ENTITY_ID}" + ) + assert issue.domain == "template" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID + assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID + + @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [ From 99d580e371c9d15c87fedc9a65ac793364ef9f78 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:28:34 +0200 Subject: [PATCH 08/36] Add reset cutting blade usage time to Husqvarna Automower (#149628) --- .../components/husqvarna_automower/button.py | 25 +++++++ .../components/husqvarna_automower/icons.json | 3 + .../husqvarna_automower/strings.json | 3 + .../snapshots/test_button.ambr | 48 ++++++++++++++ .../husqvarna_automower/test_button.py | 65 ++++++++++--------- 5 files changed, 114 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 8e58a309e59..b39f2138ab4 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -21,6 +21,20 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 +async def async_reset_cutting_blade_usage_time( + session: AutomowerSession, + mower_id: str, +) -> None: + """Reset cutting blade usage time.""" + await session.commands.reset_cutting_blade_usage_time(mower_id) + + +def reset_cutting_blade_usage_time_availability(data: MowerAttributes) -> bool: + """Return True if blade usage time is greater than 0.""" + value = data.statistics.cutting_blade_usage_time + return value is not None and value > 0 + + @dataclass(frozen=True, kw_only=True) class AutomowerButtonEntityDescription(ButtonEntityDescription): """Describes Automower button entities.""" @@ -28,6 +42,7 @@ class AutomowerButtonEntityDescription(ButtonEntityDescription): available_fn: Callable[[MowerAttributes], bool] = lambda _: True exists_fn: Callable[[MowerAttributes], bool] = lambda _: True press_fn: Callable[[AutomowerSession, str], Awaitable[Any]] + poll_after_sending: bool = False MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( @@ -43,6 +58,14 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( translation_key="sync_clock", press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), ), + AutomowerButtonEntityDescription( + key="reset_cutting_blade_usage_time", + translation_key="reset_cutting_blade_usage_time", + available_fn=reset_cutting_blade_usage_time_availability, + exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None, + press_fn=async_reset_cutting_blade_usage_time, + poll_after_sending=True, + ), ) @@ -93,3 +116,5 @@ class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity): async def async_press(self) -> None: """Send a command to the mower.""" await self.entity_description.press_fn(self.coordinator.api, self.mower_id) + if self.entity_description.poll_after_sending: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index e9d023bd3cc..5ff5940bdf4 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -8,6 +8,9 @@ "button": { "sync_clock": { "default": "mdi:clock-check-outline" + }, + "reset_cutting_blade_usage_time": { + "default": "mdi:saw-blade" } }, "number": { diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 226c9ee17f0..bd8a9346552 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -53,6 +53,9 @@ }, "sync_clock": { "name": "Sync clock" + }, + "reset_cutting_blade_usage_time": { + "name": "Reset cutting blade usage time" } }, "number": { diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index 3d48125aa9a..058fc214a91 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -47,6 +47,54 @@ 'state': 'unavailable', }) # --- +# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset cutting blade usage time', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_cutting_blade_usage_time', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_reset_cutting_blade_usage_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Reset cutting blade usage time', + }), + 'context': , + 'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button_snapshot[button.test_mower_1_sync_clock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 9fb5ad28c89..dcb4252ac8e 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -28,7 +28,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat @pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC)) -async def test_button_states_and_commands( +async def test_button_error_confirm( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -58,42 +58,43 @@ async def test_button_states_and_commands( state = hass.states.get(entity_id) assert state.state == STATE_UNKNOWN - await hass.services.async_call( - domain="button", - service=SERVICE_PRESS, - target={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_automower_client.commands.error_confirm.assert_called_once_with(TEST_MOWER_ID) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == "2023-06-05T00:16:00+00:00" - mock_automower_client.commands.error_confirm.side_effect = ApiError("Test error") - with pytest.raises( - HomeAssistantError, - match="Failed to send command: Test error", - ): - await hass.services.async_call( - domain="button", - service=SERVICE_PRESS, - target={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - +@pytest.mark.parametrize( + ("entity_id", "name", "expected_command"), + [ + ( + "button.test_mower_1_confirm_error", + "Test Mower 1 Confirm error", + "error_confirm", + ), + ( + "button.test_mower_1_sync_clock", + "Test Mower 1 Sync clock", + "set_datetime", + ), + ( + "button.test_mower_1_reset_cutting_blade_usage_time", + "Test Mower 1 Reset cutting blade usage time", + "reset_cutting_blade_usage_time", + ), + ], +) @pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) -async def test_sync_clock( +async def test_button_commands( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, values: dict[str, MowerAttributes], + entity_id: str, + name: str, + expected_command: str, ) -> None: - """Test sync clock button command.""" - entity_id = "button.test_mower_1_sync_clock" + """Test Automower button commands.""" + values[TEST_MOWER_ID].mower.is_error_confirmable = True await setup_integration(hass, mock_config_entry) + state = hass.states.get(entity_id) - assert state.name == "Test Mower 1 Sync clock" + assert state.name == name mock_automower_client.get_status.return_value = values @@ -103,11 +104,15 @@ async def test_sync_clock( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_automower_client.commands.set_datetime.assert_called_once_with(TEST_MOWER_ID) + + command_mock = getattr(mock_automower_client.commands, expected_command) + command_mock.assert_called_once_with(TEST_MOWER_ID) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" - mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error") + command_mock.reset_mock() + command_mock.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", From bfae07135a7355b173d12ed1b8b655dd48fa24cc Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 4 Aug 2025 22:35:47 +0200 Subject: [PATCH 09/36] Bump python-airos to 0.2.4 (#149885) --- homeassistant/components/airos/config_flow.py | 18 +++--- homeassistant/components/airos/coordinator.py | 18 +++--- homeassistant/components/airos/manifest.json | 2 +- homeassistant/components/airos/sensor.py | 7 --- homeassistant/components/airos/strings.json | 7 --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../airos/snapshots/test_sensor.ambr | 58 ------------------- tests/components/airos/test_config_flow.py | 12 ++-- tests/components/airos/test_sensor.py | 12 ++-- 10 files changed, 35 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index 287f54101c8..8df93c7b2c4 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -6,11 +6,11 @@ import logging from typing import Any from airos.exceptions import ( - ConnectionAuthenticationError, - ConnectionSetupError, - DataMissingError, - DeviceConnectionError, - KeyDataMissingError, + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, ) import voluptuous as vol @@ -59,13 +59,13 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): airos_data = await airos_device.status() except ( - ConnectionSetupError, - DeviceConnectionError, + AirOSConnectionSetupError, + AirOSDeviceConnectionError, ): errors["base"] = "cannot_connect" - except (ConnectionAuthenticationError, DataMissingError): + except (AirOSConnectionAuthenticationError, AirOSDataMissingError): errors["base"] = "invalid_auth" - except KeyDataMissingError: + except AirOSKeyDataMissingError: errors["base"] = "key_data_missing" except Exception: _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py index 3f0f1a12380..2fe675ee76a 100644 --- a/homeassistant/components/airos/coordinator.py +++ b/homeassistant/components/airos/coordinator.py @@ -6,10 +6,10 @@ import logging from airos.airos8 import AirOS, AirOSData from airos.exceptions import ( - ConnectionAuthenticationError, - ConnectionSetupError, - DataMissingError, - DeviceConnectionError, + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, ) from homeassistant.config_entries import ConfigEntry @@ -47,18 +47,22 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]): try: await self.airos_device.login() return await self.airos_device.status() - except (ConnectionAuthenticationError,) as err: + except (AirOSConnectionAuthenticationError,) as err: _LOGGER.exception("Error authenticating with airOS device") raise ConfigEntryError( translation_domain=DOMAIN, translation_key="invalid_auth" ) from err - except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err: + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + TimeoutError, + ) as err: _LOGGER.error("Error connecting to airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", ) from err - except (DataMissingError,) as err: + except (AirOSDataMissingError,) as err: _LOGGER.error("Expected data not returned by airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index cb6119a6fa9..758902bbaa2 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airos", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.2.1"] + "requirements": ["airos==0.2.4"] } diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 690bf21fc8e..4567261ba4d 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -69,13 +69,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( translation_key="wireless_essid", value_fn=lambda data: data.wireless.essid, ), - AirOSSensorEntityDescription( - key="wireless_mode", - translation_key="wireless_mode", - device_class=SensorDeviceClass.ENUM, - value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(), - options=WIRELESS_MODE_OPTIONS, - ), AirOSSensorEntityDescription( key="wireless_antenna_gain", translation_key="wireless_antenna_gain", diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 6823ba8520b..ff013862ee5 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -43,13 +43,6 @@ "wireless_essid": { "name": "Wireless SSID" }, - "wireless_mode": { - "name": "Wireless mode", - "state": { - "ap_ptp": "Access point", - "sta_ptp": "Station" - } - }, "wireless_antenna_gain": { "name": "Antenna gain" }, diff --git a/requirements_all.txt b/requirements_all.txt index 6e93cb6c595..f51da9d8c76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.1 +airos==0.2.4 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 945c16d3250..1a4f01dfcc7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -435,7 +435,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.1 +airos==0.2.4 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr index a92d2dc35a2..e414d35beb2 100644 --- a/tests/components/airos/snapshots/test_sensor.ambr +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -439,64 +439,6 @@ 'state': '5500', }) # --- -# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'ap_ptp', - 'sta_ptp', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wireless mode', - 'platform': 'airos', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wireless_mode', - 'unique_id': '01:23:45:67:89:AB_wireless_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'NanoStation 5AC ap name Wireless mode', - 'options': list([ - 'ap_ptp', - 'sta_ptp', - ]), - }), - 'context': , - 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'ap_ptp', - }) -# --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 9d2a6376732..212c80dfc2b 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -4,9 +4,9 @@ from typing import Any from unittest.mock import AsyncMock from airos.exceptions import ( - ConnectionAuthenticationError, - DeviceConnectionError, - KeyDataMissingError, + AirOSConnectionAuthenticationError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, ) import pytest @@ -78,9 +78,9 @@ async def test_form_duplicate_entry( @pytest.mark.parametrize( ("exception", "error"), [ - (ConnectionAuthenticationError, "invalid_auth"), - (DeviceConnectionError, "cannot_connect"), - (KeyDataMissingError, "key_data_missing"), + (AirOSConnectionAuthenticationError, "invalid_auth"), + (AirOSDeviceConnectionError, "cannot_connect"), + (AirOSKeyDataMissingError, "key_data_missing"), (Exception, "unknown"), ], ) diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py index 561741b1a2b..c9e675e7987 100644 --- a/tests/components/airos/test_sensor.py +++ b/tests/components/airos/test_sensor.py @@ -4,9 +4,9 @@ from datetime import timedelta from unittest.mock import AsyncMock from airos.exceptions import ( - ConnectionAuthenticationError, - DataMissingError, - DeviceConnectionError, + AirOSConnectionAuthenticationError, + AirOSDataMissingError, + AirOSDeviceConnectionError, ) from freezegun.api import FrozenDateTimeFactory import pytest @@ -39,10 +39,10 @@ async def test_all_entities( @pytest.mark.parametrize( ("exception"), [ - ConnectionAuthenticationError, + AirOSConnectionAuthenticationError, TimeoutError, - DeviceConnectionError, - DataMissingError, + AirOSDeviceConnectionError, + AirOSDataMissingError, ], ) async def test_sensor_update_exception_handling( From 28236aa0235bcff9ea72375f3d558efc5f371dd8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 4 Aug 2025 23:03:38 +0200 Subject: [PATCH 10/36] Reolink disable entities by default (#149986) --- homeassistant/components/reolink/number.py | 7 +++++-- .../reolink/snapshots/test_diagnostics.ambr | 12 ++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index d0222b0cffb..da879194e88 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -116,6 +116,7 @@ NUMBER_ENTITIES = ( cmd_id=[289, 438], translation_key="floodlight_brightness", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=1, native_max_value=100, @@ -407,8 +408,8 @@ NUMBER_ENTITIES = ( key="auto_track_limit_left", cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", - mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=-1, native_max_value=2700, @@ -420,8 +421,8 @@ NUMBER_ENTITIES = ( key="auto_track_limit_right", cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", - mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=-1, native_max_value=2700, @@ -435,6 +436,7 @@ NUMBER_ENTITIES = ( translation_key="auto_track_disappear_time", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -451,6 +453,7 @@ NUMBER_ENTITIES = ( translation_key="auto_track_stop_time", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index c2b059d658b..99df90340d2 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -90,8 +90,8 @@ 'null': 5, }), 'GetAiCfg': dict({ - '0': 4, - 'null': 4, + '0': 2, + 'null': 2, }), 'GetAudioAlarm': dict({ '0': 1, @@ -177,10 +177,6 @@ '0': 2, 'null': 2, }), - 'GetPtzTraceSection': dict({ - '0': 2, - 'null': 2, - }), 'GetPush': dict({ '0': 1, 'null': 2, @@ -196,8 +192,8 @@ 'null': 1, }), 'GetWhiteLed': dict({ - '0': 3, - 'null': 3, + '0': 2, + 'null': 2, }), 'GetZoomFocus': dict({ '0': 2, From d48cc03be71222fd8bc34b721b76a315feac369b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 4 Aug 2025 16:36:24 -0500 Subject: [PATCH 11/36] Bump wyoming to 1.7.2 (#150007) --- homeassistant/components/wyoming/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 31adb17d7f5..39f5267006e 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -13,6 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.7.1"], + "requirements": ["wyoming==1.7.2"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f51da9d8c76..2b07456128b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3133,7 +3133,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.7.1 +wyoming==1.7.2 # homeassistant.components.xbox xbox-webapi==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a4f01dfcc7..750be1cb2be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2586,7 +2586,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.7.1 +wyoming==1.7.2 # homeassistant.components.xbox xbox-webapi==2.1.0 From 53c9c42148229acdf2a0ca30573b7d3f96508972 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 5 Aug 2025 00:01:40 +0200 Subject: [PATCH 12/36] Use relative trigger keys (#149846) --- homeassistant/components/mqtt/icons.json | 2 +- homeassistant/components/mqtt/strings.json | 2 +- homeassistant/components/mqtt/triggers.yaml | 2 +- homeassistant/components/zwave_js/trigger.py | 4 +- .../components/zwave_js/triggers/event.py | 5 +- .../zwave_js/triggers/value_updated.py | 5 +- homeassistant/helpers/automation.py | 21 ++++++++ homeassistant/helpers/config_validation.py | 7 +++ homeassistant/helpers/trigger.py | 48 +++++++++++-------- script/hassfest/icons.py | 2 +- script/hassfest/translations.py | 2 +- script/hassfest/triggers.py | 2 +- .../components/websocket_api/test_commands.py | 4 +- tests/components/zwave_js/test_trigger.py | 8 ++-- tests/helpers/test_automation.py | 36 ++++++++++++++ tests/helpers/test_trigger.py | 26 +++++----- 16 files changed, 128 insertions(+), 48 deletions(-) create mode 100644 homeassistant/helpers/automation.py create mode 100644 tests/helpers/test_automation.py diff --git a/homeassistant/components/mqtt/icons.json b/homeassistant/components/mqtt/icons.json index 46a588a5667..1aa0902b77e 100644 --- a/homeassistant/components/mqtt/icons.json +++ b/homeassistant/components/mqtt/icons.json @@ -11,7 +11,7 @@ } }, "triggers": { - "mqtt": { + "_": { "trigger": "mdi:swap-horizontal" } } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 0e248cfd2d2..15285165047 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1285,7 +1285,7 @@ } }, "triggers": { - "mqtt": { + "_": { "name": "MQTT", "description": "When a specific message is received on a given MQTT topic.", "description_configured": "When an MQTT message has been received", diff --git a/homeassistant/components/mqtt/triggers.yaml b/homeassistant/components/mqtt/triggers.yaml index d3998674d58..0de44f4b39f 100644 --- a/homeassistant/components/mqtt/triggers.yaml +++ b/homeassistant/components/mqtt/triggers.yaml @@ -1,6 +1,6 @@ # Describes the format for MQTT triggers -mqtt: +_: fields: payload: example: "on" diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index e934faec70c..d25737ffd59 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -8,8 +8,8 @@ from homeassistant.helpers.trigger import Trigger from .triggers import event, value_updated TRIGGERS = { - event.PLATFORM_TYPE: event.EventTrigger, - value_updated.PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, + event.RELATIVE_PLATFORM_TYPE: event.EventTrigger, + value_updated.RELATIVE_PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, } diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 52c24055052..a9e37a8efa2 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -34,8 +34,11 @@ from ..helpers import ( ) from .trigger_helpers import async_bypass_dynamic_config_validation +# Relative platform type should be +RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" + # Platform type should be . -PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" +PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" def validate_non_node_event_source(obj: dict) -> dict: diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index a50053fa2db..abd231ea568 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -37,8 +37,11 @@ from ..const import ( from ..helpers import async_get_nodes_from_targets, get_device_id from .trigger_helpers import async_bypass_dynamic_config_validation +# Relative platform type should be +RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" + # Platform type should be . -PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" +PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" ATTR_FROM = "from" ATTR_TO = "to" diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py new file mode 100644 index 00000000000..52a0fc13255 --- /dev/null +++ b/homeassistant/helpers/automation.py @@ -0,0 +1,21 @@ +"""Helpers for automation.""" + + +def get_absolute_description_key(domain: str, key: str) -> str: + """Return the absolute description key.""" + if not key.startswith("_"): + return f"{domain}.{key}" + key = key[1:] # Remove leading underscore + if not key: + return domain + return key + + +def get_relative_description_key(domain: str, key: str) -> str: + """Return the relative description key.""" + platform, *subtype = key.split(".", 1) + if platform != domain: + return f"_{key}" + if not subtype: + return "_" + return subtype[0] diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index da1c1c80619..c2ebddf8012 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -644,6 +644,13 @@ def slug(value: Any) -> str: raise vol.Invalid(f"invalid slug {value} (try {slg})") +def underscore_slug(value: Any) -> str: + """Validate value is a valid slug, possibly starting with an underscore.""" + if value.startswith("_"): + return f"_{slug(value[1:])}" + return slug(value) + + def schema_with_slug_keys( value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug ) -> Callable: diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index de3f71c4834..e9c4a3d5b02 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -40,9 +40,9 @@ from homeassistant.loader import ( from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict -from homeassistant.util.yaml.loader import JSON_TYPE from . import config_validation as cv, selector +from .automation import get_absolute_description_key, get_relative_description_key from .integration_platform import async_process_integration_platforms from .selector import TargetSelector from .template import Template @@ -100,7 +100,7 @@ def starts_with_dot(key: str) -> str: _TRIGGERS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.slug: vol.Any(None, _TRIGGER_SCHEMA), + cv.underscore_slug: vol.Any(None, _TRIGGER_SCHEMA), } ) @@ -139,6 +139,7 @@ async def _register_trigger_platform( if hasattr(platform, "async_get_triggers"): for trigger_key in await platform.async_get_triggers(hass): + trigger_key = get_absolute_description_key(integration_domain, trigger_key) hass.data[TRIGGERS][trigger_key] = integration_domain new_triggers.add(trigger_key) elif hasattr(platform, "async_validate_trigger_config") or hasattr( @@ -357,9 +358,8 @@ class PluggableAction: async def _async_get_trigger_platform( - hass: HomeAssistant, config: ConfigType -) -> TriggerProtocol: - trigger_key: str = config[CONF_PLATFORM] + hass: HomeAssistant, trigger_key: str +) -> tuple[str, TriggerProtocol]: platform_and_sub_type = trigger_key.split(".") platform = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) @@ -368,7 +368,7 @@ async def _async_get_trigger_platform( except IntegrationNotFound: raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None try: - return await integration.async_get_platform("trigger") + return platform, await integration.async_get_platform("trigger") except ImportError: raise vol.Invalid( f"Integration '{platform}' does not provide trigger support" @@ -381,11 +381,14 @@ async def async_validate_trigger_config( """Validate triggers.""" config = [] for conf in trigger_config: - platform = await _async_get_trigger_platform(hass, conf) + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) - trigger_key: str = conf[CONF_PLATFORM] - if not (trigger := trigger_descriptors.get(trigger_key)): + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + if not (trigger := trigger_descriptors.get(relative_trigger_key)): raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") conf = await trigger.async_validate_trigger_config(hass, conf) elif hasattr(platform, "async_validate_trigger_config"): @@ -471,7 +474,8 @@ async def async_initialize_triggers( if not enabled: continue - platform = await _async_get_trigger_platform(hass, conf) + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) trigger_id = conf.get(CONF_ID, f"{idx}") trigger_idx = f"{idx}" trigger_alias = conf.get(CONF_ALIAS) @@ -487,7 +491,10 @@ async def async_initialize_triggers( action_wrapper = _trigger_action_wrapper(hass, action, conf) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) - trigger = trigger_descriptors[conf[CONF_PLATFORM]](hass, conf) + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + trigger = trigger_descriptors[relative_trigger_key](hass, conf) coro = trigger.async_attach_trigger(action_wrapper, info) else: coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) @@ -525,11 +532,11 @@ async def async_initialize_triggers( return remove_triggers -def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: +def _load_triggers_file(integration: Integration) -> dict[str, Any]: """Load triggers file for an integration.""" try: return cast( - JSON_TYPE, + dict[str, Any], _TRIGGERS_SCHEMA( load_yaml_dict(str(integration.file_path / "triggers.yaml")) ), @@ -549,11 +556,14 @@ def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_T def _load_triggers_files( - hass: HomeAssistant, integrations: Iterable[Integration] -) -> dict[str, JSON_TYPE]: + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: """Load trigger files for multiple integrations.""" return { - integration.domain: _load_triggers_file(hass, integration) + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_triggers_file(integration).items() + } for integration in integrations } @@ -574,7 +584,7 @@ async def async_get_all_descriptions( return descriptions_cache # Files we loaded for missing descriptions - new_triggers_descriptions: dict[str, JSON_TYPE] = {} + new_triggers_descriptions: dict[str, dict[str, Any]] = {} # We try to avoid making a copy in the event the cache is good, # but now we must make a copy in case new triggers get added # while we are loading the missing ones so we do not @@ -601,7 +611,7 @@ async def async_get_all_descriptions( if integrations: new_triggers_descriptions = await hass.async_add_executor_job( - _load_triggers_files, hass, integrations + _load_triggers_files, integrations ) # Make a copy of the old cache and add missing descriptions to it @@ -610,7 +620,7 @@ async def async_get_all_descriptions( domain = triggers[missing_trigger] if ( - yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr] + yaml_description := new_triggers_descriptions.get(domain, {}).get( missing_trigger ) ) is None: diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 79ad7eec5ff..ba6ac5e88c8 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -136,7 +136,7 @@ TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys( vol.Optional("trigger"): icon_value_validator, } ), - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 974c932ae5c..76af88f8dec 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -450,7 +450,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: slug_validator=translation_key_validator, ), }, - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ), vol.Optional("conversation"): { vol.Required("agent"): { diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index 8efaab47050..7406e6f98ea 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -50,7 +50,7 @@ TRIGGER_SCHEMA = vol.Any( TRIGGERS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, trigger.starts_with_dot)): object, - cv.slug: TRIGGER_SCHEMA, + cv.underscore_slug: TRIGGER_SCHEMA, } ) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index b513a04a40b..263cd4a4ed8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -806,10 +806,10 @@ async def test_subscribe_triggers( ) -> None: """Test trigger_platforms/subscribe command.""" sun_trigger_descriptions = """ - sun: {} + _: {} """ tag_trigger_descriptions = """ - tag: {} + _: {} """ def _load_yaml(fname, secrets=None): diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 02675544644..4186f1a778e 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -977,7 +977,7 @@ async def test_zwave_js_event_invalid_config_entry_id( async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: """Test invalid trigger configs.""" with pytest.raises(vol.Invalid): - await TRIGGERS[f"{DOMAIN}.event"].async_validate_trigger_config( + await TRIGGERS["event"].async_validate_trigger_config( hass, { "platform": f"{DOMAIN}.event", @@ -988,7 +988,7 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: ) with pytest.raises(vol.Invalid): - await TRIGGERS[f"{DOMAIN}.value_updated"].async_validate_trigger_config( + await TRIGGERS["value_updated"].async_validate_trigger_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1026,7 +1026,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( await hass.config_entries.async_unload(integration.entry_id) # Test full validation for both events - assert await TRIGGERS[f"{DOMAIN}.value_updated"].async_validate_trigger_config( + assert await TRIGGERS["value_updated"].async_validate_trigger_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1036,7 +1036,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( }, ) - assert await TRIGGERS[f"{DOMAIN}.event"].async_validate_trigger_config( + assert await TRIGGERS["event"].async_validate_trigger_config( hass, { "platform": f"{DOMAIN}.event", diff --git a/tests/helpers/test_automation.py b/tests/helpers/test_automation.py new file mode 100644 index 00000000000..1cd9944aecf --- /dev/null +++ b/tests/helpers/test_automation.py @@ -0,0 +1,36 @@ +"""Test automation helpers.""" + +import pytest + +from homeassistant.helpers.automation import ( + get_absolute_description_key, + get_relative_description_key, +) + + +@pytest.mark.parametrize( + ("relative_key", "absolute_key"), + [ + ("turned_on", "homeassistant.turned_on"), + ("_", "homeassistant"), + ("_state", "state"), + ], +) +def test_absolute_description_key(relative_key: str, absolute_key: str) -> None: + """Test absolute description key.""" + DOMAIN = "homeassistant" + assert get_absolute_description_key(DOMAIN, relative_key) == absolute_key + + +@pytest.mark.parametrize( + ("relative_key", "absolute_key"), + [ + ("turned_on", "homeassistant.turned_on"), + ("_", "homeassistant"), + ("_state", "state"), + ], +) +def test_relative_description_key(relative_key: str, absolute_key: str) -> None: + """Test relative description key.""" + DOMAIN = "homeassistant" + assert get_relative_description_key(DOMAIN, absolute_key) == relative_key diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 050420d0195..13441065691 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -50,7 +50,7 @@ async def test_trigger_subtype(hass: HomeAssistant) -> None: "homeassistant.helpers.trigger.async_get_integration", return_value=MagicMock(async_get_platform=AsyncMock()), ) as integration_mock: - await _async_get_trigger_platform(hass, {"platform": "test.subtype"}) + await _async_get_trigger_platform(hass, "test.subtype") assert integration_mock.call_args == call(hass, "test") @@ -493,8 +493,8 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: hass: HomeAssistant, ) -> dict[str, type[Trigger]]: return { - "test": MockTrigger1, - "test.trig_2": MockTrigger2, + "_": MockTrigger1, + "trig_2": MockTrigger2, } mock_integration(hass, MockModule("test")) @@ -534,7 +534,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: "sun_trigger_descriptions", [ """ - sun: + _: fields: event: example: sunrise @@ -551,7 +551,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: .anchor: &anchor - sunrise - sunset - sun: + _: fields: event: example: sunrise @@ -569,7 +569,7 @@ async def test_async_get_all_descriptions( ) -> None: """Test async_get_all_descriptions.""" tag_trigger_descriptions = """ - tag: + _: fields: entity: selector: @@ -607,7 +607,7 @@ async def test_async_get_all_descriptions( # Test we only load triggers.yaml for integrations with triggers, # system_health has no triggers - assert proxy_load_triggers_files.mock_calls[0][1][1] == unordered( + assert proxy_load_triggers_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, DOMAIN_SUN), ] @@ -615,7 +615,7 @@ async def test_async_get_all_descriptions( # system_health does not have triggers and should not be in descriptions assert descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "event": { "example": "sunrise", @@ -650,7 +650,7 @@ async def test_async_get_all_descriptions( new_descriptions = await trigger.async_get_all_descriptions(hass) assert new_descriptions is not descriptions assert new_descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "event": { "example": "sunrise", @@ -666,7 +666,7 @@ async def test_async_get_all_descriptions( "offset": {"selector": {"time": {}}}, } }, - DOMAIN_TAG: { + "tag": { "fields": { "entity": { "selector": { @@ -736,7 +736,7 @@ async def test_async_get_all_descriptions_with_bad_description( ) -> None: """Test async_get_all_descriptions.""" sun_service_descriptions = """ - sun: + _: fields: not_a_dict """ @@ -760,7 +760,7 @@ async def test_async_get_all_descriptions_with_bad_description( assert ( "Unable to parse triggers.yaml for the sun integration: " - "expected a dictionary for dictionary value @ data['sun']['fields']" + "expected a dictionary for dictionary value @ data['_']['fields']" ) in caplog.text @@ -787,7 +787,7 @@ async def test_subscribe_triggers( ) -> None: """Test trigger.async_subscribe_platform_events.""" sun_trigger_descriptions = """ - sun: {} + _: {} """ def _load_yaml(fname, secrets=None): From 68faa897adfe9236eb1da5ba0095a8139cf81324 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:48:47 +0200 Subject: [PATCH 13/36] Bump aioautomower to 2.1.2 (#150003) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index a0f25b1df4c..49eb364858f 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.1.1"] + "requirements": ["aioautomower==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2b07456128b..1622ecf923a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.1 +aioautomower==2.1.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 750be1cb2be..bf740da0e8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.1 +aioautomower==2.1.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 4c5cf028d7861e7a9082ffcc6f67b979a4f0cbba Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 5 Aug 2025 08:50:42 +0200 Subject: [PATCH 14/36] Fix Z-Wave duplicate provisioned device (#150008) --- homeassistant/components/zwave_js/__init__.py | 56 +++++++++------- tests/components/zwave_js/test_init.py | 64 ++++++++++++++++--- 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 923cd776f92..af42f024e6a 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -509,7 +509,7 @@ class ControllerEvents: ) ) - await self.async_check_preprovisioned_device(node) + await self.async_check_pre_provisioned_device(node) if node.is_controller_node: # Create a controller status sensor for each device @@ -637,8 +637,8 @@ class ControllerEvents: f"{DOMAIN}.identify_controller.{dev_id[1]}", ) - async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None: - """Check if the node was preprovisioned and update the device registry.""" + async def async_check_pre_provisioned_device(self, node: ZwaveNode) -> None: + """Check if the node was pre-provisioned and update the device registry.""" provisioning_entry = ( await self.driver_events.driver.controller.async_get_provisioning_entry( node.node_id @@ -648,29 +648,37 @@ class ControllerEvents: provisioning_entry and provisioning_entry.additional_properties and "device_id" in provisioning_entry.additional_properties - ): - preprovisioned_device = self.dev_reg.async_get( - provisioning_entry.additional_properties["device_id"] + and ( + pre_provisioned_device := self.dev_reg.async_get( + provisioning_entry.additional_properties["device_id"] + ) ) + and (dsk_identifier := (DOMAIN, f"provision_{provisioning_entry.dsk}")) + in pre_provisioned_device.identifiers + ): + driver = self.driver_events.driver + device_id = get_device_id(driver, node) + device_id_ext = get_device_id_ext(driver, node) + new_identifiers = pre_provisioned_device.identifiers.copy() + new_identifiers.remove(dsk_identifier) + new_identifiers.add(device_id) + if device_id_ext: + new_identifiers.add(device_id_ext) - if preprovisioned_device: - dsk = provisioning_entry.dsk - dsk_identifier = (DOMAIN, f"provision_{dsk}") - - # If the pre-provisioned device has the DSK identifier, remove it - if dsk_identifier in preprovisioned_device.identifiers: - driver = self.driver_events.driver - device_id = get_device_id(driver, node) - device_id_ext = get_device_id_ext(driver, node) - new_identifiers = preprovisioned_device.identifiers.copy() - new_identifiers.remove(dsk_identifier) - new_identifiers.add(device_id) - if device_id_ext: - new_identifiers.add(device_id_ext) - self.dev_reg.async_update_device( - preprovisioned_device.id, - new_identifiers=new_identifiers, - ) + if self.dev_reg.async_get_device(identifiers=new_identifiers): + # If a device entry is registered with the node ID based identifiers, + # just remove the device entry with the DSK identifier. + self.dev_reg.async_update_device( + pre_provisioned_device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + else: + # Add the node ID based identifiers to the device entry + # with the DSK identifier and remove the DSK identifier. + self.dev_reg.async_update_device( + pre_provisioned_device.id, + new_identifiers=new_identifiers, + ) async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 3c39868ff93..1aaa9013d87 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -497,17 +497,17 @@ async def test_on_node_added_ready( ) -async def test_on_node_added_preprovisioned( +async def test_check_pre_provisioned_device_update_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6_state, - client, - integration, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, ) -> None: - """Test node added event with a preprovisioned device.""" + """Test check pre-provisioned device that should update the device.""" dsk = "test" node = Node(client, deepcopy(multisensor_6_state)) - device = device_registry.async_get_or_create( + pre_provisioned_device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={(DOMAIN, f"provision_{dsk}")}, ) @@ -515,7 +515,7 @@ async def test_on_node_added_preprovisioned( { "dsk": dsk, "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], - "device_id": device.id, + "device_id": pre_provisioned_device.id, } ) with patch( @@ -526,14 +526,60 @@ async def test_on_node_added_preprovisioned( client.driver.controller.emit("node added", event) await hass.async_block_till_done() - device = device_registry.async_get(device.id) + device = device_registry.async_get(pre_provisioned_device.id) assert device assert device.identifiers == { get_device_id(client.driver, node), get_device_id_ext(client.driver, node), } assert device.sw_version == node.firmware_version - # There should only be the controller and the preprovisioned device + # There should only be the controller and the pre-provisioned device + assert len(device_registry.devices) == 2 + + +async def test_check_pre_provisioned_device_remove_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test check pre-provisioned device that should remove the device.""" + dsk = "test" + driver = client.driver + node = Node(client, deepcopy(multisensor_6_state)) + pre_provisioned_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, f"provision_{dsk}")}, + ) + extended_identifier = get_device_id_ext(driver, node) + assert extended_identifier + existing_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={ + get_device_id(driver, node), + extended_identifier, + }, + ) + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": dsk, + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": pre_provisioned_device.id, + } + ) + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entry", + side_effect=lambda id: provisioning_entry if id == node.node_id else None, + ): + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + assert not device_registry.async_get(pre_provisioned_device.id) + assert device_registry.async_get(existing_device.id) + + # There should only be the controller and the existing device assert len(device_registry.devices) == 2 From ed2ced6c36192064cf90402b2dc3d0323bac7930 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:55:54 +0200 Subject: [PATCH 15/36] Fix zimi test RuntimeWarnings (#150017) --- tests/components/zimi/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/zimi/conftest.py b/tests/components/zimi/conftest.py index 44d898deffb..b26c2f89784 100644 --- a/tests/components/zimi/conftest.py +++ b/tests/components/zimi/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for Zimi component.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -19,6 +19,8 @@ def mock_api(): """Mock the API with defaults.""" with patch("homeassistant.components.zimi.async_connect_to_controller") as mock: mock_api = mock.return_value + mock_api.describe = MagicMock() + mock_api.disconnect = MagicMock() mock_api.connect.return_value = True mock_api.mac = INPUT_MAC mock_api.brand = API_INFO["brand"] From afee936c3dedca3d700022483f54e2ed54098400 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 5 Aug 2025 09:03:23 +0200 Subject: [PATCH 16/36] Update knx-frontend to 2025.8.4.154919 (#149991) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 6a4565dde0e..f40fa028e88 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.7.23.50952" + "knx-frontend==2025.8.4.154919" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 1622ecf923a..e4049fe3850 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.7.23.50952 +knx-frontend==2025.8.4.154919 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf740da0e8d..fef79f6c3d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.7.23.50952 +knx-frontend==2025.8.4.154919 # homeassistant.components.konnected konnected==1.2.0 From 55c7c2f730280f8f27e58d521c4fedc4eb2853ee Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 09:06:15 +0200 Subject: [PATCH 17/36] Redact terminal_id in Tuya fixture files (#149957) --- tests/components/tuya/fixtures/clkg_nhyj64w2.json | 2 +- tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json | 2 +- tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json | 2 +- tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json | 2 +- tests/components/tuya/fixtures/cwjwq_agwu93lr.json | 2 +- tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json | 2 +- tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json | 2 +- tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json | 2 +- tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json | 2 +- tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json | 3 +-- tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json | 2 +- tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json | 2 +- tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json | 2 +- tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json | 2 +- tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json | 2 +- tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json | 2 +- tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json | 2 +- tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json | 2 +- tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json | 2 +- tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json | 2 +- tests/components/tuya/fixtures/tyndj_pyakuuoc.json | 2 +- tests/components/tuya/fixtures/wk_aqoouq7x.json | 2 +- tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json | 2 +- tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json | 3 +-- tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json | 2 +- tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json | 2 +- 26 files changed, 26 insertions(+), 28 deletions(-) diff --git a/tests/components/tuya/fixtures/clkg_nhyj64w2.json b/tests/components/tuya/fixtures/clkg_nhyj64w2.json index 28e3248f8b5..0f64bae778f 100644 --- a/tests/components/tuya/fixtures/clkg_nhyj64w2.json +++ b/tests/components/tuya/fixtures/clkg_nhyj64w2.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1729466466688hgsTp2", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json index 8d7e744fb52..fb544fb7d5e 100644 --- a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json +++ b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1732306182276g6jQLp", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json index 8a2fd881262..755b46fa397 100644 --- a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json +++ b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json index ff922f506c5..27d4e825ab1 100644 --- a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json +++ b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json index a4a9fc6aaff..84f76908338 100644 --- a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json +++ b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1750837476328i3TNXQ", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json index ec6f3ce5122..4bdd6f3167d 100644 --- a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json +++ b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1747045731408d0tb5M", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json index 0f5e5e5f241..695da229041 100644 --- a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json +++ b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1751729689584Vh0VoL", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json index 9cd3c4ffd6f..27c3ae0c37f 100644 --- a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json +++ b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1742695000703Ozq34h", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json index 8e9a06cc9a9..2652399bdcb 100644 --- a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json +++ b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1733006572651YokbqV", + "terminal_id": "REDACTED", "mqtt_connected": null, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json index 28f2b8e8f46..ddfbce3ae11 100644 --- a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json +++ b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json @@ -1,10 +1,9 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1732306182276g6jQLp", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "eb3e988f33c233290cfs3l", "name": "Colorful PIR Night Light", "category": "gyd", diff --git a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json index 63d9148afbf..a190161953b 100644 --- a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json +++ b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1750526976566fMhqJs", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": true, diff --git a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json index 909022793ba..642ef968608 100644 --- a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json +++ b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "CENSORED", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json index 071596e8e6c..cb158a967b4 100644 --- a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json +++ b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json index 8fa2d7b0512..5b29fd0a191 100644 --- a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json +++ b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json index 1ae5e966de7..6cae732aedf 100644 --- a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json +++ b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1737479380414pasuj4", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json index c52086213fd..c538630c542 100644 --- a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json +++ b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1751921699759JsVujI", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json index caccb0b9234..efffe12a2f9 100644 --- a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json +++ b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1708196692712PHOeqy", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json index 58cbaedb0f1..24b4dbda594 100644 --- a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json +++ b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "17421891051898r7yM6", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json index dd95050e2bf..e57e9274690 100644 --- a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json +++ b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1739471569144tcmeiO", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json index c139e79d19b..e7c79f3fb41 100644 --- a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json +++ b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1748383912663Y2lvlm", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json index 973cecabc0b..656c626c4fe 100644 --- a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json +++ b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1753247726209KOaaPc", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wk_aqoouq7x.json b/tests/components/tuya/fixtures/wk_aqoouq7x.json index 2c162a1a514..900ae356f38 100644 --- a/tests/components/tuya/fixtures/wk_aqoouq7x.json +++ b/tests/components/tuya/fixtures/wk_aqoouq7x.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1749538552551GHfV17", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json index e96389ca215..002b0609464 100644 --- a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json +++ b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "xxxxxxxxxxxxxxxxxxx", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json index 06d07a4c506..2929872f4c1 100644 --- a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json +++ b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json @@ -1,10 +1,9 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "17150293164666xhFUk", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf316b8707b061f044th18", "name": "NP DownStairs North", "category": "wsdcg", diff --git a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json index f50aab00a26..a7ab15a4511 100644 --- a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json +++ b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json index 139cf814347..797ddba3587 100644 --- a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json +++ b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1739198173271wpFacM", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, From 67c19087dd51d41a709aa86856d3975bb1873db3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Aug 2025 09:08:33 +0200 Subject: [PATCH 18/36] Bump deebot-client to 13.6.0 (#149983) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index ceb7a1da9de..ddd464bdc6a 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4049fe3850..5af92b3b745 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,7 +777,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.5.0 +deebot-client==13.6.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fef79f6c3d8..2220389d5ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -677,7 +677,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.5.0 +deebot-client==13.6.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 6b827dfc3304748ad1cdee0d6f1a3c926ea6cb9c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 09:52:29 +0200 Subject: [PATCH 19/36] Do not create Tuya fan entities without control (#149976) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tuya/fan.py | 10 ++-- tests/components/tuya/__init__.py | 6 ++- .../tuya/fixtures/fs_ibytpo6fpnugft1c.json | 23 +++++++++ tests/components/tuya/snapshots/test_fan.ambr | 50 ------------------- 4 files changed, 34 insertions(+), 55 deletions(-) create mode 100644 tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 90f4132cef0..056107d313f 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -45,6 +45,8 @@ TUYA_SUPPORT_TYPE = { "ks", } +_SWITCH_DP_CODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) + async def async_setup_entry( hass: HomeAssistant, @@ -60,7 +62,9 @@ async def async_setup_entry( entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = hass_data.manager.device_map[device_id] - if device and device.category in TUYA_SUPPORT_TYPE: + if device.category in TUYA_SUPPORT_TYPE and any( + code in device.status for code in _SWITCH_DP_CODES + ): entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) @@ -90,9 +94,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity): """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = self.find_dpcode( - (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True - ) + self._switch = self.find_dpcode(_SWITCH_DP_CODES, prefer_function=True) self._attr_preset_modes = [] if enum_type := self.find_dpcode( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index a66bd314185..742d017f285 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -43,7 +43,7 @@ DEVICE_MOCKS = { ], "cs_vmxuxszzjwp5smli": [ # https://github.com/home-assistant/core/issues/119865 - Platform.FAN, + # Platform.FAN, missing DPCodes in device status Platform.HUMIDIFIER, ], "cs_zibqa9dutqyaxym2": [ @@ -214,6 +214,10 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], + "fs_ibytpo6fpnugft1c": [ + # https://github.com/home-assistant/core/issues/135541 + # Platform.FAN, missing DPCodes in device status + ], "gyd_lgekqfxdabipm3tn": [ # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json new file mode 100644 index 00000000000..02b3808f84d --- /dev/null +++ b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "10706550a4e57c88b93a", + "name": "Ventilador Cama", + "category": "fs", + "product_id": "ibytpo6fpnugft1c", + "product_name": "Tower bladeless fan ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-10T18:47:46+00:00", + "create_time": "2025-01-10T18:47:46+00:00", + "update_time": "2025-01-10T18:47:46+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 69eb1b467e9..52c4594f37b 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -53,56 +53,6 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.dehumidifier', - '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.mock_device_id', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier ', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.dehumidifier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 7dd761c9c313cd753281aa3e12b56bf2298d647a Mon Sep 17 00:00:00 2001 From: Grzegorz M <13075554+grzesjam@users.noreply.github.com> Date: Tue, 5 Aug 2025 11:09:03 +0200 Subject: [PATCH 20/36] Bump icalendar from 6.1.0 to 6.3.1 for CalDav (#149990) --- homeassistant/components/caldav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index d0e0bd0b1d0..3b201c79e0c 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.6.0", "icalendar==6.1.0"] + "requirements": ["caldav==1.6.0", "icalendar==6.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5af92b3b745..6c3b27b3fe5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1216,7 +1216,7 @@ ibmiotf==0.3.4 ical==11.0.0 # homeassistant.components.caldav -icalendar==6.1.0 +icalendar==6.3.1 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2220389d5ec..5443891fc18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1056,7 +1056,7 @@ ibeacon-ble==1.2.0 ical==11.0.0 # homeassistant.components.caldav -icalendar==6.1.0 +icalendar==6.3.1 # homeassistant.components.ping icmplib==3.0 From 08ea64062900c9cfe3848bc5f73303580c8b1a85 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Aug 2025 11:13:32 +0200 Subject: [PATCH 21/36] Do not allow overriding users when uuid is duplicate (#149408) --- homeassistant/auth/auth_store.py | 3 +++ tests/auth/test_auth_store.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 1c2e8b0dfab..429aad09edb 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -120,6 +120,9 @@ class AuthStore: new_user = models.User(**kwargs) + while new_user.id in self._users: + new_user = models.User(**kwargs) + self._users[new_user.id] = new_user if credentials is None: diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 65bc35a5ff8..e5d3cf04a37 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -2,7 +2,7 @@ import asyncio from typing import Any -from unittest.mock import patch +from unittest.mock import PropertyMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -300,6 +300,20 @@ async def test_loading_does_not_write_right_away( assert hass_storage[auth_store.STORAGE_KEY] != {} +async def test_duplicate_uuid( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test we don't override user if we have a duplicate user ID.""" + hass_storage[auth_store.STORAGE_KEY] = MOCK_STORAGE_DATA + store = auth_store.AuthStore(hass) + await store.async_load() + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock: + hex_mock.side_effect = ["user-id", "new-id"] + user = await store.async_create_user("Test User") + assert len(hex_mock.mock_calls) == 2 + assert user.id == "new-id" + + async def test_add_remove_user_affects_tokens( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: From 02a3c5be14dade751d1f8e4142473ea5eddd4eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 5 Aug 2025 11:19:03 +0200 Subject: [PATCH 22/36] Matter pump setpoint CurrentLevel limit (#149689) --- homeassistant/components/matter/number.py | 4 +++- tests/components/matter/fixtures/nodes/pump.json | 2 +- tests/components/matter/snapshots/test_number.ambr | 2 +- tests/components/matter/test_number.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4456496d52e..d2184891dc1 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -285,7 +285,9 @@ DISCOVERY_SCHEMAS = [ native_min_value=0.5, native_step=0.5, device_to_ha=( - lambda x: None if x is None else x / 2 # Matter range (1-200) + lambda x: None + if x is None + else min(x, 200) / 2 # Matter range (1-200, capped at 200) ), ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0% mode=NumberMode.SLIDER, diff --git a/tests/components/matter/fixtures/nodes/pump.json b/tests/components/matter/fixtures/nodes/pump.json index e4afc0b4f33..6d74b3d1b89 100644 --- a/tests/components/matter/fixtures/nodes/pump.json +++ b/tests/components/matter/fixtures/nodes/pump.json @@ -203,7 +203,7 @@ "1/6/65528": [], "1/6/65529": [0, 1, 2], "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], - "1/8/0": 254, + "1/8/0": 200, "1/8/15": 0, "1/8/17": 0, "1/8/65532": 0, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index f7f467b4ed0..24a92799082 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -2189,7 +2189,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '127.0', + 'state': '100.0', }) # --- # name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry] diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index b59e6848f63..d35a889a436 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -172,7 +172,7 @@ async def test_pump_level( # CurrentLevel on LevelControl cluster state = hass.states.get("number.mock_pump_setpoint") assert state - assert state.state == "127.0" + assert state.state == "100.0" set_node_attribute(matter_node, 1, 8, 0, 100) await trigger_subscription_callback(hass, matter_client) From a6148b50cfa71eb103386e7a30afdceab909d3ad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 11:21:05 +0200 Subject: [PATCH 23/36] Add Tuya snapshots tests for button and vacuum platform (#149968) --- tests/components/tuya/__init__.py | 9 + .../tuya/fixtures/sd_lr33znaodtyarrrz.json | 476 ++++++++++++++++++ .../tuya/snapshots/test_button.ambr | 241 +++++++++ .../tuya/snapshots/test_number.ambr | 58 +++ .../tuya/snapshots/test_select.ambr | 120 +++++ .../tuya/snapshots/test_sensor.ambr | 467 +++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++ .../tuya/snapshots/test_vacuum.ambr | 64 +++ tests/components/tuya/test_button.py | 57 +++ tests/components/tuya/test_vacuum.py | 91 ++++ 10 files changed, 1631 insertions(+) create mode 100644 tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json create mode 100644 tests/components/tuya/snapshots/test_button.ambr create mode 100644 tests/components/tuya/snapshots/test_vacuum.ambr create mode 100644 tests/components/tuya/test_button.py create mode 100644 tests/components/tuya/test_vacuum.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 742d017f285..04fe034bb61 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -298,6 +298,15 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "sd_lr33znaodtyarrrz": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, + ], "sfkzq_o6dagifntoafakst": [ # https://github.com/home-assistant/core/issues/148116 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json new file mode 100644 index 00000000000..77d94cb951b --- /dev/null +++ b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json @@ -0,0 +1,476 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfa951ca98fcf64fddqlmt", + "name": "V20", + "category": "sd", + "product_id": "lr33znaodtyarrrz", + "product_name": "V20", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-23T16:37:02+00:00", + "create_time": "2025-03-23T16:37:02+00:00", + "update_time": "2025-03-23T16:37:02+00:00", + "function": { + "power_go": { + "type": "Boolean", + "value": {} + }, + "pause": { + "type": "Boolean", + "value": {} + }, + "switch_charge": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["smart", "zone", "pose", "part"] + } + }, + "suction": { + "type": "Enum", + "value": { + "range": ["gentle", "normal", "strong"] + } + }, + "cistern": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "seek": { + "type": "Boolean", + "value": {} + }, + "direction_control": { + "type": "Enum", + "value": { + "range": ["forward", "turn_left", "turn_right", "stop"] + } + }, + "reset_map": { + "type": "Boolean", + "value": {} + }, + "path_data": { + "type": "Raw", + "value": {} + }, + "command_trans": { + "type": "Raw", + "value": {} + }, + "request": { + "type": "Enum", + "value": { + "range": ["get_map", "get_path", "get_both"] + } + }, + "reset_edge_brush": { + "type": "Boolean", + "value": {} + }, + "reset_roll_brush": { + "type": "Boolean", + "value": {} + }, + "reset_filter": { + "type": "Boolean", + "value": {} + }, + "reset_duster_cloth": { + "type": "Boolean", + "value": {} + }, + "switch_disturb": { + "type": "Boolean", + "value": {} + }, + "volume_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "break_clean": { + "type": "Boolean", + "value": {} + }, + "device_timer": { + "type": "Raw", + "value": {} + }, + "disturb_time_set": { + "type": "Raw", + "value": {} + }, + "voice_data": { + "type": "Raw", + "value": {} + }, + "language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "german", + "french", + "russian", + "spanish", + "korean", + "latin", + "portuguese", + "japanese", + "italian" + ] + } + }, + "customize_mode_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "power_go": { + "type": "Boolean", + "value": {} + }, + "pause": { + "type": "Boolean", + "value": {} + }, + "switch_charge": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["smart", "zone", "pose", "part"] + } + }, + "status": { + "type": "Enum", + "value": { + "range": [ + "standby", + "zone_clean", + "part_clean", + "cleaning", + "paused", + "goto_pos", + "pos_arrived", + "pos_unarrive", + "goto_charge", + "charging", + "charge_done", + "sleep" + ] + } + }, + "clean_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9999, + "scale": 0, + "step": 1 + } + }, + "clean_area": { + "type": "Integer", + "value": { + "unit": "㎡", + "min": 0, + "max": 9999, + "scale": 0, + "step": 1 + } + }, + "electricity_left": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "suction": { + "type": "Enum", + "value": { + "range": ["closed", "gentle", "normal", "strong"] + } + }, + "cistern": { + "type": "Enum", + "value": { + "range": ["closed", "low", "middle", "high"] + } + }, + "seek": { + "type": "Boolean", + "value": {} + }, + "direction_control": { + "type": "Enum", + "value": { + "range": ["forward", "turn_left", "turn_right", "stop"] + } + }, + "reset_map": { + "type": "Boolean", + "value": {} + }, + "path_data": { + "type": "Raw", + "value": {} + }, + "command_trans": { + "type": "Raw", + "value": {} + }, + "request": { + "type": "Enum", + "value": { + "range": ["get_map", "get_path", "get_both"] + } + }, + "edge_brush": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_edge_brush": { + "type": "Boolean", + "value": {} + }, + "roll_brush": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 18000, + "scale": 0, + "step": 1 + } + }, + "reset_roll_brush": { + "type": "Boolean", + "value": {} + }, + "filter": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_filter": { + "type": "Boolean", + "value": {} + }, + "duster_cloth": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_duster_cloth": { + "type": "Boolean", + "value": {} + }, + "switch_disturb": { + "type": "Boolean", + "value": {} + }, + "volume_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "break_clean": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "low_power", + "poweroff", + "wheel_trap", + "cannot_upgrade", + "collision_stuck", + "dust_station_full", + "tile_error", + "lidar_speed_err", + "lidar_cover", + "lidar_point_err", + "front_wall_dirty", + "psd_dirty", + "middle_sweep", + "side_sweep", + "fan_speed", + "dustbox_out", + "dustbox_full", + "no_dust_box", + "dustbox_fullout", + "trapped", + "pick_up", + "no_dust_water_box", + "water_box_empty", + "forbid_area", + "land_check", + "findcharge_fail", + "battery_err", + "kit_wheel", + "kit_lidar", + "kit_water_pump" + ] + } + }, + "total_clean_area": { + "type": "Integer", + "value": { + "unit": "㎡", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "total_clean_count": { + "type": "Integer", + "value": { + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "total_clean_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "device_timer": { + "type": "Raw", + "value": {} + }, + "disturb_time_set": { + "type": "Raw", + "value": {} + }, + "device_info": { + "type": "Raw", + "value": {} + }, + "voice_data": { + "type": "Raw", + "value": {} + }, + "language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "german", + "french", + "russian", + "spanish", + "korean", + "latin", + "portuguese", + "japanese", + "italian" + ] + } + }, + "customize_mode_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "power_go": false, + "pause": false, + "switch_charge": false, + "mode": "goto_charge", + "status": "charge_done", + "clean_time": 0, + "clean_area": 0, + "electricity_left": 100, + "suction": "strong", + "cistern": "middle", + "seek": false, + "direction_control": "forward", + "reset_map": false, + "path_data": "", + "command_trans": "qgABFxc=", + "request": "get_map", + "edge_brush": 8944, + "reset_edge_brush": false, + "roll_brush": 17948, + "reset_roll_brush": false, + "filter": 8956, + "reset_filter": false, + "duster_cloth": 9000, + "reset_duster_cloth": false, + "switch_disturb": false, + "volume_set": 95, + "break_clean": true, + "fault": 0, + "total_clean_area": 24, + "total_clean_count": 1, + "total_clean_time": 42, + "device_timer": "qgADMQEAMg==", + "disturb_time_set": "qgAIMwEWAAAIAABS", + "device_info": "eyJEZXZpY2VfU04iOiJJRlYyMDI1MDExNTAyMDIwMiIsIkZpcm13YXJlX1ZlcnNpb24iOiIxLjQuMyIsIklQIjoiMTkyLjE2OC4wLjIwMyIsIk1DVV9WZXJzaW9uIjoiMC4zMTQxLjEwNyIsIk1hYyI6IjM0OjE3OjM2OkU1OjAyOjc4IiwiTW9kdWxlX1VVSUQiOiJ6ZjExYjJmNzQ4Mzg5ZTY5ZDk4NiIsIlJTU0kiOiItNTAiLCJXaUZpX05hbWUiOiJGcnl0a2lfemFfZGFybW8ifQ==", + "voice_data": "qwAAAAAHNQAAAAADZJw=", + "language": "chinese_simplified", + "customize_mode_switch": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_button.ambr b/tests/components/tuya/snapshots/test_button.ambr new file mode 100644 index 00000000000..61b62e124e5 --- /dev/null +++ b/tests/components/tuya/snapshots/test_button.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_duster_cloth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_duster_cloth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset duster cloth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_duster_cloth', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_duster_cloth', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_duster_cloth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset duster cloth', + }), + 'context': , + 'entity_id': 'button.v20_reset_duster_cloth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_edge_brush-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_edge_brush', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset edge brush', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_edge_brush', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_edge_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_edge_brush-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset edge brush', + }), + 'context': , + 'entity_id': 'button.v20_reset_edge_brush', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filter', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset filter', + }), + 'context': , + 'entity_id': 'button.v20_reset_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_map-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_map', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset map', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_map', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_map-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset map', + }), + 'context': , + 'entity_id': 'button.v20_reset_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_roll_brush-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.v20_reset_roll_brush', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset roll brush', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_roll_brush', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_roll_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_roll_brush-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset roll brush', + }), + 'context': , + 'entity_id': 'button.v20_reset_roll_brush', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index fa9d7358314..b05b45cdd48 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -469,6 +469,64 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.v20_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtvolume_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Volume', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.v20_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '95.0', + }) +# --- # name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][number.siren_veranda_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 84af76355d5..d2b3b3900e9 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -479,6 +479,126 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_mode', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Mode', + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'context': , + 'entity_id': 'select.v20_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_water_tank_adjustment-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_water_tank_adjustment', + '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': 'Water tank adjustment', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_cistern', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtcistern', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_water_tank_adjustment-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Water tank adjustment', + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.v20_water_tank_adjustment', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- # name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][select.siren_veranda_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index d2cd0eb0676..061c6c58677 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2556,6 +2556,473 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_area', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtclean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_time', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtclean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_duster_cloth_lifetime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_duster_cloth_lifetime', + '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': 'Duster cloth lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duster_cloth_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtduster_cloth', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_duster_cloth_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Duster cloth lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_duster_cloth_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9000.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_filter_lifetime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_filter_lifetime', + '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': 'Filter lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtfilter', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_filter_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Filter lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_filter_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8956.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_rolling_brush_lifetime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_rolling_brush_lifetime', + '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': 'Rolling brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rolling_brush_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtroll_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_rolling_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Rolling brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_rolling_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17948.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_side_brush_lifetime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_side_brush_lifetime', + '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': 'Side brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtedge_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_side_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Side brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_side_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8944.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_total_cleaning_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_area', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_times-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.v20_total_cleaning_times', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning times', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_times', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_times-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning times', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_times', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index aa80ac08ee5..e5b41853703 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1406,6 +1406,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][switch.v20_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.v20_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.bfa951ca98fcf64fddqlmtswitch_disturb', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][switch.v20_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Do not disturb', + }), + 'context': , + 'entity_id': 'switch.v20_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_o6dagifntoafakst][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..0425cc45060 --- /dev/null +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'gentle', + 'normal', + 'strong', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.v20', + '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': , + 'translation_key': None, + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmt', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-charging-100', + 'battery_level': 100, + 'fan_speed': 'strong', + 'fan_speed_list': list([ + 'gentle', + 'normal', + 'strong', + ]), + 'friendly_name': 'V20', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.v20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/tuya/test_button.py b/tests/components/tuya/test_button.py new file mode 100644 index 00000000000..b8c6dda4afa --- /dev/null +++ b/tests/components/tuya/test_button.py @@ -0,0 +1,57 @@ +"""Test Tuya button platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.BUTTON in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.BUTTON not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py new file mode 100644 index 00000000000..1caf298f3c4 --- /dev/null +++ b/tests/components/tuya/test_vacuum.py @@ -0,0 +1,91 @@ +"""Test Tuya vacuum platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.VACUUM in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.VACUUM not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["sd_lr33znaodtyarrrz"], +) +async def test_return_home( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test return home service.""" + # Based on #141278 + entity_id = "vacuum.v20" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + { + "entity_id": entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_charge", "value": True}] + ) From 803654223a26e1688f726e16b82bec3209b08f80 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:23:06 +0200 Subject: [PATCH 24/36] Revert "Do not create Tuya fan entities without control" (#150032) --- homeassistant/components/tuya/fan.py | 10 ++-- tests/components/tuya/__init__.py | 6 +-- .../tuya/fixtures/fs_ibytpo6fpnugft1c.json | 23 --------- tests/components/tuya/snapshots/test_fan.ambr | 50 +++++++++++++++++++ 4 files changed, 55 insertions(+), 34 deletions(-) delete mode 100644 tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 056107d313f..90f4132cef0 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -45,8 +45,6 @@ TUYA_SUPPORT_TYPE = { "ks", } -_SWITCH_DP_CODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) - async def async_setup_entry( hass: HomeAssistant, @@ -62,9 +60,7 @@ async def async_setup_entry( entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = hass_data.manager.device_map[device_id] - if device.category in TUYA_SUPPORT_TYPE and any( - code in device.status for code in _SWITCH_DP_CODES - ): + if device and device.category in TUYA_SUPPORT_TYPE: entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) @@ -94,7 +90,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity): """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = self.find_dpcode(_SWITCH_DP_CODES, prefer_function=True) + self._switch = self.find_dpcode( + (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True + ) self._attr_preset_modes = [] if enum_type := self.find_dpcode( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 04fe034bb61..1498cd954d0 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -43,7 +43,7 @@ DEVICE_MOCKS = { ], "cs_vmxuxszzjwp5smli": [ # https://github.com/home-assistant/core/issues/119865 - # Platform.FAN, missing DPCodes in device status + Platform.FAN, Platform.HUMIDIFIER, ], "cs_zibqa9dutqyaxym2": [ @@ -214,10 +214,6 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], - "fs_ibytpo6fpnugft1c": [ - # https://github.com/home-assistant/core/issues/135541 - # Platform.FAN, missing DPCodes in device status - ], "gyd_lgekqfxdabipm3tn": [ # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json deleted file mode 100644 index 02b3808f84d..00000000000 --- a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", - "mqtt_connected": true, - "disabled_by": null, - "disabled_polling": false, - "id": "10706550a4e57c88b93a", - "name": "Ventilador Cama", - "category": "fs", - "product_id": "ibytpo6fpnugft1c", - "product_name": "Tower bladeless fan ", - "online": true, - "sub": false, - "time_zone": "+01:00", - "active_time": "2025-01-10T18:47:46+00:00", - "create_time": "2025-01-10T18:47:46+00:00", - "update_time": "2025-01-10T18:47:46+00:00", - "function": {}, - "status_range": {}, - "status": {}, - "set_up": true, - "support_local": true -} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 52c4594f37b..69eb1b467e9 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -53,6 +53,56 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier', + '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.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 064a63fe1f4aa2070e522316a4a2979c8f3a3038 Mon Sep 17 00:00:00 2001 From: Nippey Date: Tue, 5 Aug 2025 12:54:40 +0200 Subject: [PATCH 25/36] Add support for Tuya "Bresser 7-in-1 Weatherstation" (#149498) --- homeassistant/components/tuya/const.py | 15 + homeassistant/components/tuya/sensor.py | 60 +++ homeassistant/components/tuya/strings.json | 12 + .../tuya/snapshots/test_sensor.ambr | 495 ++++++++++++++++++ 4 files changed, 582 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 87f80755e8b..e5a37d272ef 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -109,6 +109,7 @@ class DPCode(StrEnum): ANION = "anion" # Ionizer unit ARM_DOWN_PERCENT = "arm_down_percent" ARM_UP_PERCENT = "arm_up_percent" + ATMOSPHERIC_PRESSTURE = "atmospheric_pressture" # Typo is in Tuya API BASIC_ANTI_FLICKER = "basic_anti_flicker" BASIC_DEVICE_VOLUME = "basic_device_volume" BASIC_FLIP = "basic_flip" @@ -215,6 +216,10 @@ class DPCode(StrEnum): HUMIDITY = "humidity" # Humidity HUMIDITY_CURRENT = "humidity_current" # Current humidity HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity + HUMIDITY_OUTDOOR = "humidity_outdoor" # Outdoor humidity + HUMIDITY_OUTDOOR_1 = "humidity_outdoor_1" # Outdoor humidity + HUMIDITY_OUTDOOR_2 = "humidity_outdoor_2" # Outdoor humidity + HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" # Outdoor humidity HUMIDITY_SET = "humidity_set" # Humidity setting HUMIDITY_VALUE = "humidity_value" # Humidity IPC_WORK_MODE = "ipc_work_mode" @@ -360,6 +365,15 @@ class DPCode(StrEnum): TEMP_CURRENT_EXTERNAL = ( "temp_current_external" # Current external temperature in Celsius ) + TEMP_CURRENT_EXTERNAL_1 = ( + "temp_current_external_1" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_2 = ( + "temp_current_external_2" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_3 = ( + "temp_current_external_3" # Current external temperature in Celsius + ) TEMP_CURRENT_EXTERNAL_F = ( "temp_current_external_f" # Current external temperature in Fahrenheit ) @@ -405,6 +419,7 @@ class DPCode(StrEnum): WINDOW_CHECK = "window_check" WINDOW_STATE = "window_state" WINDSPEED = "windspeed" + WINDSPEED_AVG = "windspeed_avg" WIRELESS_BATTERYLOCK = "wireless_batterylock" WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index da7a57b1be2..aa53c8c6f02 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -846,6 +846,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_1, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_2, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_3, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "3"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, translation_key="humidity", @@ -858,12 +879,51 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR, + translation_key="humidity_outdoor", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_1, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_2, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_3, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "3"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ATMOSPHERIC_PRESSTURE, + translation_key="air_pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, translation_key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.WINDSPEED_AVG, + translation_key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), *BATTERY_SENSORS, ), # Gas Detector diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 97d623d7c21..ee9548cdef9 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -502,9 +502,21 @@ "temperature_external": { "name": "Probe temperature" }, + "indexed_temperature_external": { + "name": "Probe temperature channel {index}" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, + "humidity_outdoor": { + "name": "Outdoor humidity" + }, + "indexed_humidity_outdoor": { + "name": "Outdoor humidity channel {index}" + }, + "air_pressure": { + "name": "Air pressure" + }, "pm25": { "name": "[%key:component::sensor::entity_component::pm25::name%]" }, diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 061c6c58677..42b395b5e34 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2025,6 +2025,62 @@ 'state': 'middle', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air pressure', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_pressure', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzatmospheric_pressture', + 'unit_of_measurement': 'hPa', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Air pressure', + 'state_class': , + 'unit_of_measurement': 'hPa', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1004.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2179,6 +2235,218 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_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': 'Outdoor humidity channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_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': 'Outdoor humidity channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_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': 'Outdoor humidity channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2235,6 +2503,174 @@ 'state': '-40.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_1', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.3', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_2', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.2', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_3', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2291,6 +2727,65 @@ 'state': '24.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzwindspeed_avg', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 20fdec9e9ccb8bc44cde030de6c579df2ee7bed0 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:56:27 +0200 Subject: [PATCH 26/36] Reduce polling in Husqvarna Automower (#149255) Co-authored-by: Joost Lekkerkerker --- .../husqvarna_automower/coordinator.py | 60 ++++- .../husqvarna_automower/test_init.py | 211 +++++++++++++++++- 2 files changed, 267 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 91adc8c75ec..a037df474cc 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import override @@ -14,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerDictionary +from aioautomower.model import MowerDictionary, MowerStates from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry @@ -29,7 +29,9 @@ _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time - +PONG_TIMEOUT = timedelta(seconds=90) +PING_INTERVAL = timedelta(seconds=10) +PING_TIMEOUT = timedelta(seconds=5) type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] @@ -58,6 +60,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] + self.pong: datetime | None = None + self.websocket_alive: bool = False + self._watchdog_task: asyncio.Task | None = None @override @callback @@ -71,6 +76,18 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): await self.api.connect() self.api.register_data_callback(self.handle_websocket_updates) self.ws_connected = True + + def start_watchdog() -> None: + if self._watchdog_task is not None and not self._watchdog_task.done(): + _LOGGER.debug("Cancelling previous watchdog task") + self._watchdog_task.cancel() + self._watchdog_task = self.config_entry.async_create_background_task( + self.hass, + self._pong_watchdog(), + "websocket_watchdog", + ) + + self.api.register_ws_ready_callback(start_watchdog) try: data = await self.api.get_status() except ApiError as err: @@ -93,6 +110,19 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): mower_data.capabilities.work_areas for mower_data in self.data.values() ): self._async_add_remove_work_areas() + if ( + not self._should_poll() + and self.update_interval is not None + and self.websocket_alive + ): + _LOGGER.debug("All mowers inactive and websocket alive: stop polling") + self.update_interval = None + if self.update_interval is None and self._should_poll(): + _LOGGER.debug( + "Polling re-enabled via WebSocket: at least one mower active" + ) + self.update_interval = SCAN_INTERVAL + self.hass.async_create_task(self.async_request_refresh()) @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: @@ -161,6 +191,30 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): "reconnect_task", ) + def _should_poll(self) -> bool: + """Return True if at least one mower is connected and at least one is not OFF.""" + return any(mower.metadata.connected for mower in self.data.values()) and any( + mower.mower.state != MowerStates.OFF for mower in self.data.values() + ) + + async def _pong_watchdog(self) -> None: + _LOGGER.debug("Watchdog started") + try: + while True: + _LOGGER.debug("Sending ping") + self.websocket_alive = await self.api.send_empty_message() + _LOGGER.debug("Ping result: %s", self.websocket_alive) + + await asyncio.sleep(60) + _LOGGER.debug("Websocket alive %s", self.websocket_alive) + if not self.websocket_alive: + _LOGGER.debug("No pong received → restart polling") + if self.update_interval is None: + self.update_interval = SCAN_INTERVAL + await self.async_request_refresh() + except asyncio.CancelledError: + _LOGGER.debug("Watchdog cancelled") + def _async_add_remove_devices(self) -> None: """Add new devices and remove orphaned devices from the registry.""" current_devices = set(self.data) diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 81874cea8a7..a157380ab3c 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -14,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import Calendar, MowerAttributes, WorkArea +from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -484,3 +484,212 @@ async def test_add_and_remove_work_area( - ADDITIONAL_NUMBER_ENTITIES - ADDITIONAL_SENSOR_ENTITIES ) + + +@pytest.mark.parametrize( + ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), + [ + (True, MowerStates.OFF, False, MowerStates.OFF), # False + (False, MowerStates.PAUSED, False, MowerStates.OFF), # False + (False, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.PAUSED), # False + (True, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.OFF), # False + ], +) +async def test_dynamic_polling( + hass: HomeAssistant, + mock_automower_client, + mock_config_entry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], + mower1_connected: bool, + mower1_state: MowerStates, + mower2_connected: bool, + mower2_state: MowerStates, +) -> None: + """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. + + and that the pong callback stops polling when all mowers are inactive. + """ + websocket_values = deepcopy(values) + poll_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["data_cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + callback_holder["ws_ready_cb"] = cb + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + + await setup_integration(hass, mock_config_entry) + + assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" + callback_holder["ws_ready_cb"]() + + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + # websocket is still active, but mowers are inactive -> no polling required + poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected + poll_values[TEST_MOWER_ID].mower.state = mower1_state + poll_values["1234"].metadata.connected = mower2_connected + poll_values["1234"].mower.state = mower2_state + + mock_automower_client.get_status.return_value = poll_values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 3 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + # websocket is still active, and mowers are active -> polling required + mock_automower_client.get_status.reset_mock() + assert mock_automower_client.get_status.call_count == 0 + poll_values[TEST_MOWER_ID].metadata.connected = True + poll_values[TEST_MOWER_ID].mower.state = MowerStates.PAUSED + poll_values["1234"].metadata.connected = False + poll_values["1234"].mower.state = MowerStates.OFF + websocket_values = deepcopy(poll_values) + callback_holder["data_cb"](websocket_values) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + +@pytest.mark.parametrize( + ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), + [ + (True, MowerStates.OFF, False, MowerStates.OFF), # False + (False, MowerStates.PAUSED, False, MowerStates.OFF), # False + (False, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.PAUSED), # False + (True, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.OFF), # False + ], +) +async def test_websocket_watchdog( + hass: HomeAssistant, + mock_automower_client, + mock_config_entry, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + values: dict[str, MowerAttributes], + mower1_connected: bool, + mower1_state: MowerStates, + mower2_connected: bool, + mower2_state: MowerStates, +) -> None: + """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. + + and that the pong callback stops polling when all mowers are inactive. + """ + poll_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["data_cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + callback_holder["ws_ready_cb"] = cb + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + + await setup_integration(hass, mock_config_entry) + + assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" + callback_holder["ws_ready_cb"]() + + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + # websocket is still active, but mowers are inactive -> no polling required + poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected + poll_values[TEST_MOWER_ID].mower.state = mower1_state + poll_values["1234"].metadata.connected = mower2_connected + poll_values["1234"].mower.state = mower2_state + + mock_automower_client.get_status.return_value = poll_values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 3 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + # Simulate Pong loss and reset mock -> polling required + mock_automower_client.send_empty_message.return_value = False + mock_automower_client.get_status.reset_mock() + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 0 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 From 3a643572018f02549a981becb6a3e8eaaa767c05 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:22:45 +0200 Subject: [PATCH 27/36] Fix Tuya fan speeds with numeric values (#149971) --- homeassistant/components/tuya/fan.py | 4 +- tests/components/tuya/__init__.py | 20 ++ .../tuya/fixtures/cs_qhxmvae667uap4zh.json | 32 +++ .../tuya/fixtures/fs_g0ewlb1vmwqljzji.json | 134 +++++++++++ .../tuya/fixtures/fs_ibytpo6fpnugft1c.json | 23 ++ .../tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json | 86 +++++++ tests/components/tuya/snapshots/test_fan.ambr | 221 ++++++++++++++++++ .../tuya/snapshots/test_humidifier.ambr | 55 +++++ .../components/tuya/snapshots/test_light.ambr | 81 +++++++ .../tuya/snapshots/test_select.ambr | 63 +++++ .../tuya/snapshots/test_switch.ambr | 192 +++++++++++++++ 11 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json create mode 100644 tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json create mode 100644 tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json create mode 100644 tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 90f4132cef0..4c97b857fb7 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -267,7 +267,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity): return int(self._speed.remap_value_to(value, 1, 100)) if self._speeds is not None: - if (value := self.device.status.get(self._speeds.dpcode)) is None: + if ( + value := self.device.status.get(self._speeds.dpcode) + ) is None or value not in self._speeds.range: return None return ordered_list_item_to_percentage(self._speeds.range, value) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1498cd954d0..181f0a97763 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -41,6 +41,11 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "cs_qhxmvae667uap4zh": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.FAN, + Platform.HUMIDIFIER, + ], "cs_vmxuxszzjwp5smli": [ # https://github.com/home-assistant/core/issues/119865 Platform.FAN, @@ -214,6 +219,16 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], + "fs_g0ewlb1vmwqljzji": [ + # https://github.com/home-assistant/core/issues/141231 + Platform.FAN, + Platform.LIGHT, + Platform.SELECT, + ], + "fs_ibytpo6fpnugft1c": [ + # https://github.com/home-assistant/core/issues/135541 + Platform.FAN, + ], "gyd_lgekqfxdabipm3tn": [ # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, @@ -227,6 +242,11 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, ], + "kj_CAjWAxBUZt7QZHfz": [ + # https://github.com/home-assistant/core/issues/146023 + Platform.FAN, + Platform.SWITCH, + ], "kj_yrzylxax1qspdgpp": [ # https://github.com/orgs/home-assistant/discussions/61 Platform.FAN, diff --git a/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json new file mode 100644 index 00000000000..9b0b704e3de --- /dev/null +++ b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json @@ -0,0 +1,32 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "28403630e8db84b7a963", + "name": "DryFix", + "category": "cs", + "product_id": "qhxmvae667uap4zh", + "product_name": "", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-04-03T13:10:02+00:00", + "create_time": "2024-04-03T13:10:02+00:00", + "update_time": "2024-04-03T13:10:02+00:00", + "function": {}, + "status_range": { + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json new file mode 100644 index 00000000000..3aae03c904a --- /dev/null +++ b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json @@ -0,0 +1,134 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "XXX", + "name": "Ceiling Fan With Light", + "category": "fs", + "product_id": "g0ewlb1vmwqljzji", + "product_name": "Ceiling Fan With Light", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-22T22:57:04+00:00", + "create_time": "2025-03-22T22:57:04+00:00", + "update_time": "2025-03-22T22:57:04+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["normal", "sleep", "nature"] + } + }, + "fan_speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "light": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "4h", "8h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["normal", "sleep", "nature"] + } + }, + "fan_speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "light": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "4h", "8h"] + } + } + }, + "status": { + "switch": true, + "mode": "normal", + "fan_speed": 1, + "fan_direction": "reverse", + "light": true, + "bright_value": 100, + "temp_value": 0, + "countdown_set": "off" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json new file mode 100644 index 00000000000..02b3808f84d --- /dev/null +++ b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "10706550a4e57c88b93a", + "name": "Ventilador Cama", + "category": "fs", + "product_id": "ibytpo6fpnugft1c", + "product_name": "Tower bladeless fan ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-10T18:47:46+00:00", + "create_time": "2025-01-10T18:47:46+00:00", + "update_time": "2025-01-10T18:47:46+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json new file mode 100644 index 00000000000..5758fce2152 --- /dev/null +++ b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json @@ -0,0 +1,86 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "152027113c6105cce49c", + "name": "HL400", + "category": "kj", + "product_id": "CAjWAxBUZt7QZHfz", + "product_name": "air purifier", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-13T11:02:55+00:00", + "create_time": "2025-05-13T11:02:55+00:00", + "update_time": "2025-05-13T11:02:55+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "uv": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3"] + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "pm25": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 500, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "lock": false, + "anion": true, + "speed": 3, + "uv": true, + "pm25": 45 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 69eb1b467e9..7532023860b 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -53,6 +53,56 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][fan.dryfix-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dryfix', + '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.28403630e8db84b7a963', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][fan.dryfix-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DryFix', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dryfix', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -153,6 +203,177 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][fan.ceiling_fan_with_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'normal', + 'sleep', + 'nature', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan_with_light', + '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': , + 'translation_key': None, + 'unique_id': 'tuya.XXX', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][fan.ceiling_fan_with_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'reverse', + 'friendly_name': 'Ceiling Fan With Light', + 'percentage': None, + 'percentage_step': 16.666666666666668, + 'preset_mode': 'normal', + 'preset_modes': list([ + 'normal', + 'sleep', + 'nature', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ceiling_fan_with_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fs_ibytpo6fpnugft1c][fan.ventilador_cama-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ventilador_cama', + '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.10706550a4e57c88b93a', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_ibytpo6fpnugft1c][fan.ventilador_cama-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ventilador Cama', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ventilador_cama', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][fan.hl400-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hl400', + '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': , + 'translation_key': None, + 'unique_id': 'tuya.152027113c6105cce49c', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][fan.hl400-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400', + 'percentage': None, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hl400', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index 25bb1799dc8..33034e3f6e7 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -54,6 +54,61 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][humidifier.dryfix-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dryfix', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.28403630e8db84b7a963switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][humidifier.dryfix-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'DryFix', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dryfix', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][humidifier.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index b5dca58f8e7..4f2f22ddf2b 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -2022,6 +2022,87 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][light.ceiling_fan_with_light-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.ceiling_fan_with_light', + '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.XXXlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][light.ceiling_fan_with_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'Ceiling Fan With Light', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.ceiling_fan_with_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[gyd_lgekqfxdabipm3tn][light.colorful_pir_night_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index d2b3b3900e9..0efd1e96840 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -414,6 +414,69 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][select.ceiling_fan_with_light_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.XXXcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][select.ceiling_fan_with_light_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ceiling Fan With Light Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'context': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index e5b41853703..175296d180e 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -970,6 +970,198 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.hl400_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.152027113c6105cce49clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Child lock', + }), + 'context': , + 'entity_id': 'switch.hl400_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_ionizer-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.hl400_ionizer', + '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': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.152027113c6105cce49canion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Ionizer', + }), + 'context': , + 'entity_id': 'switch.hl400_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.hl400_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.152027113c6105cce49cswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Power', + }), + 'context': , + 'entity_id': 'switch.hl400_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_uv_sterilization-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.hl400_uv_sterilization', + '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': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.152027113c6105cce49cuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 UV sterilization', + }), + 'context': , + 'entity_id': 'switch.hl400_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 31631cc882475863788350e68954f0399c8bed6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:40:01 +0200 Subject: [PATCH 28/36] Bump actions/ai-inference from 1.2.4 to 1.2.7 (#150038) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index fd7ed1a38a9..cbb82bc742e 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.2.4 + uses: actions/ai-inference@v1.2.7 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index eefc896bfcb..816d5cf8476 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.2.4 + uses: actions/ai-inference@v1.2.7 with: model: openai/gpt-4o-mini system-prompt: | From 9d8e253ad34d811b53ffad86a744f3f7b093b9ce Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 5 Aug 2025 14:15:08 +0100 Subject: [PATCH 29/36] Default to zero quantity on new todo items in Mealie (#150047) --- homeassistant/components/mealie/todo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index e31af281783..c701af2865c 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -130,6 +130,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): list_id=self._shopping_list_id, note=item.summary.strip() if item.summary else item.summary, position=position, + quantity=0.0, ) try: await self.coordinator.client.add_shopping_item(new_shopping_item) From ffb2a693f48c8926498d17c42c1034481372212b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Aug 2025 15:22:21 +0200 Subject: [PATCH 30/36] Ignore vacuum entities that properly deprecate battery (#150043) --- homeassistant/components/vacuum/__init__.py | 14 ++++++++++++-- tests/components/template/test_vacuum.py | 5 ++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 4b7a6907455..11db9108db3 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -79,6 +79,8 @@ DEFAULT_NAME = "Vacuum cleaner robot" _DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1") _DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1") +_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",) + class VacuumEntityFeature(IntFlag): """Supported features of the vacuum entity.""" @@ -321,7 +323,11 @@ class StateVacuumEntity( Integrations should implement a sensor instead. """ - if self.platform: + if ( + self.platform + and self.platform.platform_name + not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS + ): # Don't report usage until after entity added to hass, after init report_usage( f"is setting the {property} which has been deprecated." @@ -341,7 +347,11 @@ class StateVacuumEntity( Integrations should remove the battery supported feature when migrating battery level and icon to a sensor. """ - if self.platform: + if ( + self.platform + and self.platform.platform_name + not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS + ): # Don't report usage until after entity added to hass, after init report_usage( f"is setting the battery supported feature which has been deprecated." diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index d0e6488e46e..8c2773956b2 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -603,7 +603,9 @@ async def test_battery_level_template( ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_battery_level_template_repair( - hass: HomeAssistant, issue_registry: ir.IssueRegistry + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test battery_level template raises issue.""" # Ensure trigger entity templates are rendered @@ -618,6 +620,7 @@ async def test_battery_level_template_repair( assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID + assert "Detected that integration 'template' is setting the" not in caplog.text @pytest.mark.parametrize( From f714388130384e7881aec3745e7f0de5237e6ac1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:25:58 +0200 Subject: [PATCH 31/36] Bump docker/login-action from 3.4.0 to 3.5.0 (#150034) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 82009751763..572a041fb7f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -256,7 +256,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -330,14 +330,14 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -502,7 +502,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 70c9b1f0953a9c9debf7484593b637d94504f9a4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:31:02 +0200 Subject: [PATCH 32/36] Implement snapshot testing for Plugwise button platform (#149984) --- tests/components/plugwise/conftest.py | 24 ++++++++- .../plugwise/snapshots/test_button.ambr | 50 +++++++++++++++++++ tests/components/plugwise/test_button.py | 40 ++++++++------- 3 files changed, 94 insertions(+), 20 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_button.ambr diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index bc3de313a86..7120e0f87f0 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -130,6 +130,28 @@ def mock_smile_config_flow() -> Generator[MagicMock]: yield api +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms.""" + return [] + + +@pytest.fixture +async def setup_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms, +) -> AsyncGenerator[None]: + """Set up one or all platforms.""" + + mock_config_entry.add_to_hass(hass) + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry + + @pytest.fixture def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" diff --git a/tests/components/plugwise/snapshots/test_button.ambr b/tests/components/plugwise/snapshots/test_button.ambr new file mode 100644 index 00000000000..900d85db527 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_button.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_adam_button_snapshot[platforms0][button.adam_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.adam_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot', + 'unique_id': 'fe799307f1624099878210aa0b9f1475-reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_button_snapshot[platforms0][button.adam_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Adam Reboot', + }), + 'context': , + 'entity_id': 'button.adam_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/plugwise/test_button.py b/tests/components/plugwise/test_button.py index 23003b3ffe6..8667e2ef893 100644 --- a/tests/components/plugwise/test_button.py +++ b/tests/components/plugwise/test_button.py @@ -2,32 +2,34 @@ from unittest.mock import MagicMock -from homeassistant.components.button import ( - DOMAIN as BUTTON_DOMAIN, - SERVICE_PRESS, - ButtonDeviceClass, -) -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_adam_reboot_button( +@pytest.mark.parametrize("platforms", [(BUTTON_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_button_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam button snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +async def test_adam_press_reboot_button( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test creation of button entities.""" - state = hass.states.get("button.adam_reboot") - assert state - assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART - - registry = er.async_get(hass) - entry = registry.async_get("button.adam_reboot") - assert entry - assert entry.unique_id == "fe799307f1624099878210aa0b9f1475-reboot" - + """Test pressing of button entity.""" await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, From 4e40e9bf74c24c6751c149ea13405d36a123a9f7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:56:03 +0200 Subject: [PATCH 33/36] Update mypy-dev to 1.18.0a4 (#150005) --- homeassistant/components/reolink/media_source.py | 4 +--- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 9c8c685d898..f716340e06e 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -422,9 +422,7 @@ class ReolinkVODMediaSource(MediaSource): file_name = f"{file.start_time.time()} {file.duration}" if file.triggers != file.triggers.NONE: file_name += " " + " ".join( - str(trigger.name).title() - for trigger in file.triggers - if trigger != trigger.NONE + str(trigger.name).title() for trigger in file.triggers ) children.append( diff --git a/requirements_test.txt b/requirements_test.txt index 6c0fc02df58..592d4758340 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.18.0a3 +mypy-dev==1.18.0a4 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 From 37510aa316bd46dbbd8f983fbfcec543eac7c704 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Aug 2025 16:01:47 +0200 Subject: [PATCH 34/36] Update frontend to 20250805.0 (#150049) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 706940f5da7..7be7dd1def9 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250731.0"] + "requirements": ["home-assistant-frontend==20250805.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bca5e4648af..fe57522530f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==4.0.1 hass-nabucasa==0.111.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250805.0 home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6c3b27b3fe5..446d3f4c768 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ hole==0.9.0 holidays==0.77 # homeassistant.components.frontend -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250805.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5443891fc18..3c0564e4100 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ hole==0.9.0 holidays==0.77 # homeassistant.components.frontend -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250805.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 From fe95f6e1c5ebf15da0987c1a69fc23f05cbf36cf Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 5 Aug 2025 16:12:55 +0200 Subject: [PATCH 35/36] Improve downloader service (#150046) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/downloader/__init__.py | 3 + .../components/downloader/services.py | 38 +++++--- .../components/downloader/strings.json | 8 ++ tests/components/downloader/conftest.py | 94 +++++++++++++++++++ tests/components/downloader/test_init.py | 66 ++++++++++--- tests/components/downloader/test_services.py | 54 +++++++++++ 6 files changed, 237 insertions(+), 26 deletions(-) create mode 100644 tests/components/downloader/conftest.py create mode 100644 tests/components/downloader/test_services.py diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index eb844ad8d3f..8b33c1d7ed3 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -18,6 +18,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If path is relative, we assume relative to Home Assistant config dir if not os.path.isabs(download_path): download_path = hass.config.path(download_path) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DOWNLOAD_DIR: download_path} + ) if not await hass.async_add_executor_job(os.path.isdir, download_path): _LOGGER.error( diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index bb1b968dd99..0ccaee232d7 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -11,6 +11,7 @@ import requests import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -34,24 +35,33 @@ def download_file(service: ServiceCall) -> None: entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0] download_path = entry.data[CONF_DOWNLOAD_DIR] + url: str = service.data[ATTR_URL] + subdir: str | None = service.data.get(ATTR_SUBDIR) + target_filename: str | None = service.data.get(ATTR_FILENAME) + overwrite: bool = service.data[ATTR_OVERWRITE] + + if subdir: + # Check the path + try: + raise_if_invalid_path(subdir) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="subdir_invalid", + translation_placeholders={"subdir": subdir}, + ) from err + if os.path.isabs(subdir): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="subdir_not_relative", + translation_placeholders={"subdir": subdir}, + ) def do_download() -> None: """Download the file.""" + final_path = None + filename = target_filename try: - url = service.data[ATTR_URL] - - subdir = service.data.get(ATTR_SUBDIR) - - filename = service.data.get(ATTR_FILENAME) - - overwrite = service.data.get(ATTR_OVERWRITE) - - if subdir: - # Check the path - raise_if_invalid_path(subdir) - - final_path = None - req = requests.get(url, stream=True, timeout=10) if req.status_code != HTTPStatus.OK: diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index 7db7ea459d7..98c4a0a6c82 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -12,6 +12,14 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "exceptions": { + "subdir_invalid": { + "message": "Invalid subdirectory, got: {subdir}" + }, + "subdir_not_relative": { + "message": "Subdirectory must be relative, got: {subdir}" + } + }, "services": { "download_file": { "name": "Download file", diff --git a/tests/components/downloader/conftest.py b/tests/components/downloader/conftest.py new file mode 100644 index 00000000000..3bb63455ccc --- /dev/null +++ b/tests/components/downloader/conftest.py @@ -0,0 +1,94 @@ +"""Provide common fixtures for downloader tests.""" + +import asyncio +from pathlib import Path + +import pytest +from requests_mock import Mocker + +from homeassistant.components.downloader.const import ( + CONF_DOWNLOAD_DIR, + DOMAIN, + DOWNLOAD_COMPLETED_EVENT, + DOWNLOAD_FAILED_EVENT, +) +from homeassistant.core import Event, HomeAssistant, callback + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the downloader integration for testing.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + download_dir: Path, +) -> MockConfigEntry: + """Return a mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DOWNLOAD_DIR: str(download_dir)}, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def download_dir(tmp_path: Path) -> Path: + """Return a download directory.""" + return tmp_path + + +@pytest.fixture(autouse=True) +def mock_download_request( + requests_mock: Mocker, + download_url: str, +) -> None: + """Mock the download request.""" + requests_mock.get(download_url, text="{'one': 1}") + + +@pytest.fixture +def download_url() -> str: + """Return a mock download URL.""" + return "http://example.com/file.txt" + + +@pytest.fixture +def download_completed(hass: HomeAssistant) -> asyncio.Event: + """Return an asyncio event to wait for download completion.""" + download_event = asyncio.Event() + + @callback + def download_set(event: Event[dict[str, str]]) -> None: + """Set the event when download is completed.""" + download_event.set() + + hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", download_set) + + return download_event + + +@pytest.fixture +def download_failed(hass: HomeAssistant) -> asyncio.Event: + """Return an asyncio event to wait for download failure.""" + download_event = asyncio.Event() + + @callback + def download_set(event: Event[dict[str, str]]) -> None: + """Set the event when download has failed.""" + download_event.set() + + hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", download_set) + + return download_event diff --git a/tests/components/downloader/test_init.py b/tests/components/downloader/test_init.py index e74eb376b39..fe001838afe 100644 --- a/tests/components/downloader/test_init.py +++ b/tests/components/downloader/test_init.py @@ -1,6 +1,8 @@ """Tests for the downloader component init.""" -from unittest.mock import patch +from pathlib import Path + +import pytest from homeassistant.components.downloader.const import ( CONF_DOWNLOAD_DIR, @@ -13,17 +15,57 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_initialization(hass: HomeAssistant) -> None: - """Test the initialization of the downloader component.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_DOWNLOAD_DIR: "/test_dir", - }, - ) - config_entry.add_to_hass(hass) - with patch("os.path.isdir", return_value=True): - assert await hass.config_entries.async_setup(config_entry.entry_id) +@pytest.fixture +def download_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + """Return a download directory.""" + if hasattr(request, "param"): + return tmp_path / request.param + return tmp_path + + +async def test_config_entry_setup( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Test config entry setup.""" + config_entry = setup_integration assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) assert config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_setup_relative_directory( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config entry setup with a relative download directory.""" + relative_directory = "downloads" + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_DOWNLOAD_DIR: relative_directory}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # The config entry will fail to set up since the directory does not exist. + # This is not relevant for this test. + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.data[CONF_DOWNLOAD_DIR] == hass.config.path( + relative_directory + ) + + +@pytest.mark.parametrize( + "download_dir", + [ + "not_existing_path", + ], + indirect=True, +) +async def test_config_entry_setup_not_existing_directory( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry setup without existing download directory.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert not hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/downloader/test_services.py b/tests/components/downloader/test_services.py new file mode 100644 index 00000000000..fbdc088021a --- /dev/null +++ b/tests/components/downloader/test_services.py @@ -0,0 +1,54 @@ +"""Test downloader services.""" + +import asyncio +from contextlib import AbstractContextManager, nullcontext as does_not_raise + +import pytest + +from homeassistant.components.downloader.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize( + ("subdir", "expected_result"), + [ + ("test", does_not_raise()), + ("test/path", does_not_raise()), + ("~test/path", pytest.raises(ServiceValidationError)), + ("~/../test/path", pytest.raises(ServiceValidationError)), + ("../test/path", pytest.raises(ServiceValidationError)), + (".../test/path", pytest.raises(ServiceValidationError)), + ("/test/path", pytest.raises(ServiceValidationError)), + ], +) +async def test_download_invalid_subdir( + hass: HomeAssistant, + download_completed: asyncio.Event, + download_failed: asyncio.Event, + download_url: str, + subdir: str, + expected_result: AbstractContextManager, +) -> None: + """Test service invalid subdirectory.""" + + async def call_service() -> None: + """Call the download service.""" + completed = hass.async_create_task(download_completed.wait()) + failed = hass.async_create_task(download_failed.wait()) + await hass.services.async_call( + DOMAIN, + "download_file", + { + "url": download_url, + "subdir": subdir, + "filename": "file.txt", + "overwrite": True, + }, + blocking=True, + ) + await asyncio.wait((completed, failed), return_when=asyncio.FIRST_COMPLETED) + + with expected_result: + await call_service() From 991c9008bdb5f7d431cc9c260c28c319e978a357 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Aug 2025 16:35:41 +0200 Subject: [PATCH 36/36] Change AI task strings (#150051) --- .../google_generative_ai_conversation/strings.json | 6 +++--- homeassistant/components/ollama/strings.json | 6 +++--- homeassistant/components/open_router/strings.json | 4 ++-- homeassistant/components/openai_conversation/strings.json | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 11e7c75c8ba..545436da590 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -123,10 +123,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "set_options": { "data": { diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 4f3cb3c30c0..9ec03cef69a 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -58,10 +58,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "set_options": { "data": { diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index e73a65cd178..43a27a91959 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -52,9 +52,9 @@ } }, "initiate_flow": { - "user": "Add Generate data with AI service" + "user": "Add AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 4446eff2c9e..a1bf236f19b 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -73,10 +73,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "init": { "data": {