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 01/13] 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 056107d313f6a6..90f4132cef0758 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -45,8 +45,6 @@ "ks", } -_SWITCH_DP_CODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) - async def async_setup_entry( hass: HomeAssistant, @@ -62,9 +60,7 @@ def async_discover_device(device_ids: list[str]) -> None: 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 @@ def __init__( """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 04fe034bb61c27..1498cd954d05a7 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -43,7 +43,7 @@ ], "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 @@ # 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 02b3808f84d15b..00000000000000 --- 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 52c4594f37b9f5..69eb1b467e9ae1 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 02/13] 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 87f80755e8b053..e5a37d272eff22 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 da7a57b1be232f..aa53c8c6f02b30 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -846,6 +846,27 @@ class TuyaSensorEntityDescription(SensorEntityDescription): 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 @@ class TuyaSensorEntityDescription(SensorEntityDescription): 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 97d623d7c2126a..ee9548cdef903b 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 061c6c58677086..42b395b5e3427b 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 03/13] 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 91adc8c75ecfcf..a037df474cc362 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -4,7 +4,7 @@ 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 @@ 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 @@ 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 @@ def __init__( 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 @@ async def _async_update_data(self) -> 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 @@ def _on_data_update(self) -> None: 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 @@ async def client_listen( "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 81874cea8a7328..a157380ab3c416 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -14,7 +14,7 @@ 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 @@ def fake_register_websocket_response( - 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 04/13] 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 90f4132cef0758..4c97b857fb7269 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -267,7 +267,9 @@ def percentage(self) -> int | None: 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 1498cd954d05a7..181f0a977633a2 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -41,6 +41,11 @@ 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 @@ # 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 @@ # 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 00000000000000..9b0b704e3dec3f --- /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 00000000000000..3aae03c904a26e --- /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 00000000000000..02b3808f84d15b --- /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 00000000000000..5758fce2152fb0 --- /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 69eb1b467e9ae1..7532023860b6a1 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 25bb1799dc8536..33034e3f6e75ab 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 b5dca58f8e7c3d..4f2f22ddf2b823 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 d2b3b3900e92fe..0efd1e9684050a 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 e5b41853703b5a..175296d180ebea 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 05/13] 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 fd7ed1a38a9cd9..cbb82bc742e934 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 eefc896bfcb079..816d5cf8476777 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 06/13] 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 e31af281783d78..c701af2865cdf4 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -130,6 +130,7 @@ async def async_create_todo_item(self, item: TodoItem) -> None: 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 07/13] 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 4b7a6907455b52..11db9108db33a7 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -79,6 +79,8 @@ _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 @@ def _report_deprecated_battery_properties(self, property: str) -> None: 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 @@ def _report_deprecated_battery_feature(self) -> None: 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 d0e6488e46e620..8c2773956b2944 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 08/13] 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 820097517634a3..572a041fb7feaa 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 09/13] 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 | 38 +++++++------- 3 files changed, 93 insertions(+), 19 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 bc3de313a86453..7120e0f87f00b5 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 00000000000000..900d85db527c0b --- /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 23003b3ffe661f..8667e2ef893013 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( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +@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 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 + """Test Adam button snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) - registry = er.async_get(hass) - entry = registry.async_get("button.adam_reboot") - assert entry - assert entry.unique_id == "fe799307f1624099878210aa0b9f1475-reboot" +async def test_adam_press_reboot_button( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """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 10/13] 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 9c8c685d898fa4..f716340e06ef6f 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -422,9 +422,7 @@ async def _async_generate_camera_files( 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 6c0fc02df58053..592d475834088d 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 11/13] 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 706940f5da7a29..7be7dd1def9ebb 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 bca5e4648af0bd..fe57522530ff06 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 6c3b27b3fe5393..446d3f4c76894c 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 5443891fc18bb9..3c0564e4100bcb 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 12/13] 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 eb844ad8d3f98a..8b33c1d7ed3ff3 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 bb1b968dd99e81..0ccaee232d73c0 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -11,6 +11,7 @@ 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 7db7ea459d756c..98c4a0a6c82e3f 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 00000000000000..3bb63455ccc2da --- /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 e74eb376b39913..fe001838afea6b 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 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 00000000000000..fbdc088021aa4f --- /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 13/13] 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 11e7c75c8ba03c..545436da59037b 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 4f3cb3c30c0fd0..9ec03cef69aab6 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 e73a65cd178cae..43a27a91959edf 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 4446eff2c9ea16..a1bf236f19b7f8 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": {