diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index b87a569abda..482c5c4a9e6 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -29,6 +29,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.FAN, Platform.LOCK, Platform.SENSOR, Platform.SWITCH, @@ -51,6 +52,7 @@ class SwitchbotDevices: sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -96,7 +98,6 @@ async def make_switchbot_devices( for device in devices ] ) - return devices_data @@ -177,6 +178,16 @@ async def make_device_data( else: devices_data.switches.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in [ + "Battery Circulator Fan", + "Circulator Fan", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.fans.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py new file mode 100644 index 00000000000..d7cf82520ec --- /dev/null +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -0,0 +1,120 @@ +"""Support for the Switchbot Battery Circulator fan.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + BatteryCirculatorFanCommands, + BatteryCirculatorFanMode, + CommonCommands, +) + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudFan(data.api, device, coordinator) + for device, coordinator in data.devices.fans + ) + + +class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): + """Representation of a SwitchBot Battery Circulator Fan.""" + + _attr_name = None + + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = list(BatteryCirculatorFanMode) + + _attr_is_on: bool | None = None + + @property + def is_on(self) -> bool | None: + """Return true if the entity is on.""" + return self._attr_is_on + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + power: str = self.coordinator.data["power"] + mode: str = self.coordinator.data["mode"] + fan_speed: str = self.coordinator.data["fanSpeed"] + self._attr_is_on = power == "on" + self._attr_preset_mode = mode + self._attr_percentage = int(fan_speed) + self._attr_supported_features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + if self.is_on and self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + self._attr_supported_features |= FanEntityFeature.SET_SPEED + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + await self.send_api_command(CommonCommands.ON) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(self.preset_mode), + ) + if self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(self.percentage), + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(BatteryCirculatorFanMode.DIRECT.value), + ) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(percentage), + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=preset_mode, + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index f93df234289..75e994b484e 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -91,6 +91,7 @@ CO2_DESCRIPTION = SensorEntityDescription( SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), + "Battery Circulator Fan": (BATTERY_DESCRIPTION,), "Meter": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7132fd6a414..96db2ab555a 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -165,6 +165,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = YoLinkHomeStore( yolink_home, device_coordinators ) + + # Clean up yolink devices which are not associated to the account anymore. + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + for identifier in device_entry.identifiers: + if ( + identifier[0] == DOMAIN + and device_coordinators.get(identifier[1]) is None + ): + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def async_yolink_unload(event) -> None: diff --git a/tests/components/switchbot_cloud/test_fan.py b/tests/components/switchbot_cloud/test_fan.py new file mode 100644 index 00000000000..4a9eb527818 --- /dev/null +++ b/tests/components/switchbot_cloud/test_fan.py @@ -0,0 +1,187 @@ +"""Test for the Switchbot Battery Circulator Fan.""" + +from unittest.mock import patch + +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + None, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_UNKNOWN + + +async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None: + """Test turning on the fan.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +async def test_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turning off the fan.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +async def test_set_percentage( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set percentage.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "5"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 5}, + blocking=True, + ) + mock_send_command.assert_called() + + +async def test_set_preset_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set preset mode.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "baby", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "baby"}, + blocking=True, + ) + mock_send_command.assert_called_once() diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 09606c7e116..c8f54fa275d 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -75,6 +75,14 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "qxj_temp_humidity_external_probe": [ + # https://github.com/home-assistant/core/issues/136472 + Platform.SENSOR, + ], + "qxj_weather_station": [ + # https://github.com/orgs/home-assistant/discussions/318 + Platform.SENSOR, + ], "rqbj_gas_sensor": [ # https://github.com/orgs/home-assistant/discussions/100 Platform.BINARY_SENSOR, diff --git a/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json b/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json new file mode 100644 index 00000000000..caccb0b9234 --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json @@ -0,0 +1,65 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1708196692712PHOeqy", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff00f6abe0563b284t77p", + "name": "Frysen", + "category": "qxj", + "product_id": "is2indt9nlth6esa", + "product_name": "T & H Sensor with external probe", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-27T15:19:27+00:00", + "create_time": "2025-01-27T15:19:27+00:00", + "update_time": "2025-01-27T15:19:27+00:00", + "function": {}, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "temp_current": 222, + "humidity_value": 38, + "battery_state": "high", + "temp_current_external": -130 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qxj_weather_station.json b/tests/components/tuya/fixtures/qxj_weather_station.json new file mode 100644 index 00000000000..c52086213fd --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_weather_station.json @@ -0,0 +1,412 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1751921699759JsVujI", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf84c743a84eb2c8abeurz", + "name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "category": "qxj", + "product_id": "fsea1lat3vuktbt6", + "product_name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-07T17:43:41+00:00", + "create_time": "2025-07-07T17:43:41+00:00", + "update_time": "2025-07-07T17:43:41+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + } + }, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + }, + "fault_type": { + "type": "Enum", + "value": { + "range": [ + "normal", + "ch1_offline", + "ch2_offline", + "ch3_offline", + "offline" + ] + } + }, + "battery_status": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_1": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_2": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_3": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_1": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_1": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_2": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_3": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_3": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "atmospheric_pressture": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 3000, + "max": 12000, + "scale": 1, + "step": 1 + } + }, + "pressure_drop": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "windspeed_avg": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "windspeed_gust": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "wind_direct": { + "type": "Enum", + "value": { + "range": [ + "north", + "north_north_east", + "north_east", + "east_north_east", + "east", + "east_south_east", + "south_east", + "south_south_east", + "south", + "south_south_west", + "south_west", + "west_south_west", + "west", + "west_north_west", + "north_west", + "north_north_west" + ] + } + }, + "rain_24h": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 1000000, + "scale": 3, + "step": 1 + } + }, + "rain_rate": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 999999, + "scale": 3, + "step": 1 + } + }, + "uv_index": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 180, + "scale": 1, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 238000, + "scale": 0, + "step": 100 + } + }, + "dew_point_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 800, + "scale": 1, + "step": 1 + } + }, + "feellike_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "heat_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 260, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "windchill_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "com_index": { + "type": "Enum", + "value": { + "range": ["moist", "dry", "comfortable"] + } + } + }, + "status": { + "temp_current": 240, + "humidity_value": 52, + "battery_state": "high", + "temp_unit_convert": "c", + "windspeed_unit_convert": "m_s", + "pressure_unit_convert": "hpa", + "rain_unit_convert": "mm", + "bright_unit_convert": "lux", + "fault_type": "normal", + "battery_status": "low", + "battery_state_1": "high", + "battery_state_2": "high", + "battery_state_3": "low", + "temp_current_external": -400, + "humidity_outdoor": 0, + "temp_current_external_1": 193, + "humidity_outdoor_1": 99, + "temp_current_external_2": 252, + "humidity_outdoor_2": 0, + "temp_current_external_3": -400, + "humidity_outdoor_3": 0, + "atmospheric_pressture": 10040, + "pressure_drop": 0, + "windspeed_avg": 0, + "windspeed_gust": 0, + "wind_direct": "none", + "rain_24h": 0, + "rain_rate": 0, + "uv_index": 0, + "bright_value": 0, + "dew_point_temp": -400, + "feellike_temp": -650, + "heat_index": 260, + "windchill_index": -650, + "com_index": "none" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f63c75567ef..8cf51062a73 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1265,6 +1265,485 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frysen_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bff00f6abe0563b284t77pbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frysen Battery state', + }), + 'context': , + 'entity_id': 'sensor.frysen_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_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.frysen_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': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bff00f6abe0563b284t77phumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Frysen Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.frysen_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frysen_probe_temperature', + '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', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-13.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frysen_temperature', + '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': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Battery state', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_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_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': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-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_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + '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', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + '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': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- # name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/yolink/conftest.py b/tests/components/yolink/conftest.py new file mode 100644 index 00000000000..2090cd57f2f --- /dev/null +++ b/tests/components/yolink/conftest.py @@ -0,0 +1,77 @@ +"""Provide common fixtures for the YoLink integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from yolink.home_manager import YoLinkHome + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.yolink.api import ConfigEntryAuth +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "12345" +CLIENT_SECRET = "6789" +DOMAIN = "yolink" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="mock_auth_manager") +def mock_auth_manager() -> Generator[MagicMock]: + """Mock the authentication manager.""" + with patch( + "homeassistant.components.yolink.api.ConfigEntryAuth", autospec=True + ) as mock_auth: + mock_auth.return_value = MagicMock(spec=ConfigEntryAuth) + yield mock_auth + + +@pytest.fixture(name="mock_yolink_home") +def mock_yolink_home() -> Generator[AsyncMock]: + """Mock YoLink home instance.""" + with patch( + "homeassistant.components.yolink.YoLinkHome", autospec=True + ) as mock_home: + mock_home.return_value = AsyncMock(spec=YoLinkHome) + yield mock_home + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a config entry for YoLink.""" + config_entry = MockConfigEntry( + unique_id=DOMAIN, + domain=DOMAIN, + title="yolink", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "create", + }, + }, + options={}, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/yolink/test_init.py b/tests/components/yolink/test_init.py new file mode 100644 index 00000000000..11d0528dcce --- /dev/null +++ b/tests/components/yolink/test_init.py @@ -0,0 +1,38 @@ +"""Tests for the yolink integration.""" + +import pytest + +from homeassistant.components.yolink import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("setup_credentials", "mock_auth_manager", "mock_yolink_home") +async def test_device_remove_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can only remove a device that no longer exists.""" + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "stale_device_id")}, + ) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + device_entry = device_entries[0] + assert device_entry.identifiers == {(DOMAIN, "stale_device_id")} + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 0