diff --git a/.coveragerc b/.coveragerc index 8a5b90b5d76..47376833679 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1368,7 +1368,6 @@ omit = homeassistant/components/verisure/sensor.py homeassistant/components/verisure/switch.py homeassistant/components/versasense/* - homeassistant/components/vesync/__init__.py homeassistant/components/vesync/common.py homeassistant/components/vesync/fan.py homeassistant/components/vesync/light.py diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py new file mode 100644 index 00000000000..8043e93b9e4 --- /dev/null +++ b/homeassistant/components/vesync/diagnostics.py @@ -0,0 +1,119 @@ +"""Diagnostics support for VeSync.""" +from __future__ import annotations + +from typing import Any + +from pyvesync import VeSync + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .common import VeSyncBaseDevice +from .const import DOMAIN, VS_MANAGER + +KEYS_TO_REDACT = {"manager", "uuid", "mac_id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + manager: VeSync = hass.data[DOMAIN][VS_MANAGER] + + data = { + DOMAIN: { + "bulb_count": len(manager.bulbs), + "fan_count": len(manager.fans), + "outlets_count": len(manager.outlets), + "switch_count": len(manager.switches), + "timezone": manager.time_zone, + }, + "devices": { + "bulbs": [_redact_device_values(device) for device in manager.bulbs], + "fans": [_redact_device_values(device) for device in manager.fans], + "outlets": [_redact_device_values(device) for device in manager.outlets], + "switches": [_redact_device_values(device) for device in manager.switches], + }, + } + + return data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + manager: VeSync = hass.data[DOMAIN][VS_MANAGER] + device_dict = _build_device_dict(manager) + vesync_device_id = next(iden[1] for iden in device.identifiers if iden[0] == DOMAIN) + + # Base device information, without sensitive information. + data = _redact_device_values(device_dict[vesync_device_id]) + + data["home_assistant"] = { + "name": device.name, + "name_by_user": device.name_by_user, + "disabled": device.disabled, + "disabled_by": device.disabled_by, + "entities": [], + } + + # Gather information how this VeSync device is represented in Home Assistant + entity_registry = er.async_get(hass) + hass_entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + + for entity_entry in hass_entities: + state = hass.states.get(entity_entry.entity_id) + state_dict = None + if state: + state_dict = dict(state.as_dict()) + # The context doesn't provide useful information in this case. + state_dict.pop("context", None) + + data["home_assistant"]["entities"].append( + { + "domain": entity_entry.domain, + "entity_id": entity_entry.entity_id, + "entity_category": entity_entry.entity_category, + "device_class": entity_entry.device_class, + "original_device_class": entity_entry.original_device_class, + "name": entity_entry.name, + "original_name": entity_entry.original_name, + "icon": entity_entry.icon, + "original_icon": entity_entry.original_icon, + "unit_of_measurement": entity_entry.unit_of_measurement, + "state": state_dict, + "disabled": entity_entry.disabled, + "disabled_by": entity_entry.disabled_by, + } + ) + + return data + + +def _build_device_dict(manager: VeSync) -> dict: + """Build a dictionary of ALL VeSync devices.""" + device_dict = {x.cid: x for x in manager.switches} + device_dict.update({x.cid: x for x in manager.fans}) + device_dict.update({x.cid: x for x in manager.outlets}) + device_dict.update({x.cid: x for x in manager.bulbs}) + return device_dict + + +def _redact_device_values(device: VeSyncBaseDevice) -> dict: + """Rebuild and redact values of a VeSync device.""" + data = {} + for key, item in device.__dict__.items(): + if key not in KEYS_TO_REDACT: + data[key] = item + else: + data[key] = REDACTED + + return data diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py new file mode 100644 index 00000000000..39cd66a5936 --- /dev/null +++ b/tests/components/vesync/common.py @@ -0,0 +1,72 @@ +"""Common methods used across tests for VeSync.""" +import json + +from tests.common import load_fixture + + +def call_api_side_effect__no_devices(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture("vesync_api_call__devices__no_devices.json", "vesync") + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + + +def call_api_side_effect__single_humidifier(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture( + "vesync_api_call__devices__single_humidifier.json", "vesync" + ) + ), + 200, + ) + elif args[0] == "/cloud/v2/deviceManaged/bypassV2" and kwargs["method"] == "post": + return ( + json.loads( + load_fixture( + "vesync_api_call__device_details__single_humidifier.json", "vesync" + ) + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + + +def call_api_side_effect__single_fan(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture("vesync_api_call__devices__single_fan.json", "vesync") + ), + 200, + ) + elif ( + args[0] == "/131airPurifier/v1/device/deviceDetail" + and kwargs["method"] == "post" + ): + return ( + json.loads( + load_fixture( + "vesync_api_call__device_details__single_fan.json", "vesync" + ) + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py new file mode 100644 index 00000000000..8815a4b9748 --- /dev/null +++ b/tests/components/vesync/conftest.py @@ -0,0 +1,104 @@ +"""Configuration for VeSync tests.""" +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from pyvesync import VeSync +from pyvesync.vesyncbulb import VeSyncBulb +from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncSwitch + +from homeassistant.components.vesync import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config) -> ConfigEntry: + """Create a mock VeSync config entry.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture() -> ConfigType: + """Create hass config fixture.""" + return {DOMAIN: {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}} + + +@pytest.fixture(name="manager") +def manager_fixture() -> VeSync: + """Create a mock VeSync manager fixture.""" + + outlets = [] + switches = [] + fans = [] + bulbs = [] + + mock_vesync = Mock(VeSync) + mock_vesync.login = Mock(return_value=True) + mock_vesync.update = Mock() + mock_vesync.outlets = outlets + mock_vesync.switches = switches + mock_vesync.fans = fans + mock_vesync.bulbs = bulbs + mock_vesync._dev_list = { + "fans": fans, + "outlets": outlets, + "switches": switches, + "bulbs": bulbs, + } + mock_vesync.account_id = "account_id" + mock_vesync.time_zone = "America/New_York" + mock = Mock(return_value=mock_vesync) + + with patch("homeassistant.components.vesync.VeSync", new=mock): + yield mock_vesync + + +@pytest.fixture(name="fan") +def fan_fixture(): + """Create a mock VeSync fan fixture.""" + mock_fixture = Mock(VeSyncAirBypass) + return mock_fixture + + +@pytest.fixture(name="bulb") +def bulb_fixture(): + """Create a mock VeSync bulb fixture.""" + mock_fixture = Mock(VeSyncBulb) + return mock_fixture + + +@pytest.fixture(name="switch") +def switch_fixture(): + """Create a mock VeSync switch fixture.""" + mock_fixture = Mock(VeSyncSwitch) + mock_fixture.is_dimmable = Mock(return_value=False) + return mock_fixture + + +@pytest.fixture(name="dimmable_switch") +def dimmable_switch_fixture(): + """Create a mock VeSync switch fixture.""" + mock_fixture = Mock(VeSyncSwitch) + mock_fixture.is_dimmable = Mock(return_value=True) + return mock_fixture + + +@pytest.fixture(name="outlet") +def outlet_fixture(): + """Create a mock VeSync outlet fixture.""" + mock_fixture = Mock(VeSyncOutlet) + return mock_fixture diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json new file mode 100644 index 00000000000..35b5a02fb3d --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json @@ -0,0 +1,15 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "enabled": false, + "mode": "humidity" + } + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json new file mode 100644 index 00000000000..f9e4b0e18f1 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json @@ -0,0 +1,27 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "enabled": false, + "mist_virtual_level": 9, + "mist_level": 3, + "mode": "humidity", + "water_lacks": false, + "water_tank_lifted": false, + "humidity": 35, + "humidity_high": false, + "display": false, + "warm_enabled": false, + "warm_level": 0, + "automatic_stop_reach_target": true, + "configuration": { "auto_target_humidity": 60, "display": true }, + "extension": { "schedule_count": 0, "timer_remain": 0 } + } + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json b/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json new file mode 100644 index 00000000000..f1eaa523101 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json @@ -0,0 +1,11 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json new file mode 100644 index 00000000000..2951ab63f03 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [ + { + "deviceRegion": "US", + "isOwner": true, + "authKey": null, + "deviceName": "Fan", + "deviceImg": "", + "cid": "abcdefghabcdefghabcdefghabcdefgh", + "deviceStatus": "off", + "connectionStatus": "online", + "connectionType": "WiFi+BTOnboarding+BTNotify", + "deviceType": "LV-PUR131S", + "type": "wifi-air", + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "WFON_AHM_LV-PUR131S_US", + "macID": "00:00:00:00:00:00", + "mode": null, + "speed": null, + "currentFirmVersion": null, + "subDeviceNo": null, + "subDeviceType": null, + "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", + "subDeviceList": null, + "extension": null, + "deviceProp": null + } + ] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json new file mode 100644 index 00000000000..0f043394402 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [ + { + "deviceRegion": "US", + "isOwner": true, + "authKey": null, + "deviceName": "Humidifier", + "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", + "cid": "abcdefghabcdefghabcdefghabcdefgh", + "deviceStatus": "off", + "connectionStatus": "online", + "connectionType": "WiFi+BTOnboarding+BTNotify", + "deviceType": "LUH-A602S-WUS", + "type": "wifi-air", + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "WFON_AHM_LUH-A602S-WUS_US", + "macID": "00:00:00:00:00:00", + "mode": null, + "speed": null, + "currentFirmVersion": null, + "subDeviceNo": null, + "subDeviceType": null, + "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", + "subDeviceList": null, + "extension": null, + "deviceProp": null + } + ] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__login.json b/tests/components/vesync/fixtures/vesync_api_call__login.json new file mode 100644 index 00000000000..4a956f67341 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__login.json @@ -0,0 +1,9 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "accountID": "9999999", + "token": "TOKEN" + } +} diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..33378d7ccde --- /dev/null +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -0,0 +1,272 @@ +# serializer version: 1 +# name: test_async_get_config_entry_diagnostics__no_devices + dict({ + 'devices': dict({ + 'bulbs': list([ + ]), + 'fans': list([ + ]), + 'outlets': list([ + ]), + 'switches': list([ + ]), + }), + 'vesync': dict({ + 'bulb_count': 0, + 'fan_count': 0, + 'outlets_count': 0, + 'switch_count': 0, + 'timezone': 'US/Pacific', + }), + }) +# --- +# name: test_async_get_config_entry_diagnostics__single_humidifier + dict({ + 'devices': dict({ + 'bulbs': list([ + ]), + 'fans': list([ + dict({ + '_api_modes': list([ + 'getHumidifierStatus', + 'setAutomaticStop', + 'setSwitch', + 'setNightLightBrightness', + 'setVirtualLevel', + 'setTargetHumidity', + 'setHumidityMode', + 'setDisplay', + 'setLevel', + ]), + 'cid': 'abcdefghabcdefghabcdefghabcdefgh', + 'config': dict({ + 'auto_target_humidity': 60, + 'automatic_stop': True, + 'display': True, + }), + 'config_dict': dict({ + 'features': list([ + 'warm_mist', + 'nightlight', + ]), + 'mist_levels': list([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + 'mist_modes': list([ + 'humidity', + 'sleep', + 'manual', + ]), + 'models': list([ + 'LUH-A602S-WUSR', + 'LUH-A602S-WUS', + 'LUH-A602S-WEUR', + 'LUH-A602S-WEU', + 'LUH-A602S-WJP', + ]), + 'module': 'VeSyncHumid200300S', + 'warm_mist_levels': list([ + 0, + 1, + 2, + 3, + ]), + }), + 'config_module': 'WFON_AHM_LUH-A602S-WUS_US', + 'connection_status': 'online', + 'connection_type': 'WiFi+BTOnboarding+BTNotify', + 'current_firm_version': None, + 'details': dict({ + 'automatic_stop_reach_target': True, + 'display': False, + 'humidity': 35, + 'humidity_high': False, + 'mist_level': 3, + 'mist_virtual_level': 9, + 'mode': 'humidity', + 'night_light_brightness': 0, + 'warm_mist_enabled': False, + 'warm_mist_level': 0, + 'water_lacks': False, + 'water_tank_lifted': False, + }), + 'device_image': 'https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png', + 'device_name': 'Humidifier', + 'device_status': 'off', + 'device_type': 'LUH-A602S-WUS', + 'enabled': False, + 'extension': None, + 'features': list([ + 'warm_mist', + 'nightlight', + ]), + 'mac_id': '**REDACTED**', + 'manager': '**REDACTED**', + 'mist_levels': list([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + 'mist_modes': list([ + 'humidity', + 'sleep', + 'manual', + ]), + 'mode': None, + 'night_light': True, + 'speed': None, + 'sub_device_no': None, + 'type': 'wifi-air', + 'uuid': '**REDACTED**', + 'warm_mist_feature': True, + 'warm_mist_levels': list([ + 0, + 1, + 2, + 3, + ]), + }), + ]), + 'outlets': list([ + ]), + 'switches': list([ + ]), + }), + 'vesync': dict({ + 'bulb_count': 0, + 'fan_count': 1, + 'outlets_count': 0, + 'switch_count': 0, + 'timezone': 'US/Pacific', + }), + }) +# --- +# name: test_async_get_device_diagnostics__single_fan + dict({ + 'cid': 'abcdefghabcdefghabcdefghabcdefgh', + 'config': dict({ + }), + 'config_module': 'WFON_AHM_LV-PUR131S_US', + 'connection_status': 'unknown', + 'connection_type': 'WiFi+BTOnboarding+BTNotify', + 'current_firm_version': None, + 'details': dict({ + 'active_time': 0, + 'air_quality': 'unknown', + 'filter_life': dict({ + }), + 'level': 0, + 'screen_status': 'unknown', + }), + 'device_image': '', + 'device_name': 'Fan', + 'device_status': 'unknown', + 'device_type': 'LV-PUR131S', + 'extension': None, + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.fan', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan', + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + 'supported_features': 1, + }), + 'entity_id': 'fan.fan', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.fan_filter_life', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan Filter Life', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Filter Life', + 'state_class': 'measurement', + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.fan_filter_life', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': '%', + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fan_air_quality', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan Air Quality', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Air Quality', + }), + 'entity_id': 'sensor.fan_air_quality', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), + ]), + 'name': 'Fan', + 'name_by_user': None, + }), + 'mac_id': '**REDACTED**', + 'manager': '**REDACTED**', + 'mode': None, + 'speed': None, + 'sub_device_no': None, + 'type': 'wifi-air', + 'uuid': '**REDACTED**', + }) +# --- diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py new file mode 100644 index 00000000000..eb802bb41b8 --- /dev/null +++ b/tests/components/vesync/test_diagnostics.py @@ -0,0 +1,99 @@ +"""Tests for the diagnostics data provided by the VeSync integration.""" +from unittest.mock import patch + +from pyvesync.helpers import Helpers +from syrupy import SnapshotAssertion +from syrupy.matchers import path_type + +from homeassistant.components.vesync.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from .common import ( + call_api_side_effect__no_devices, + call_api_side_effect__single_fan, + call_api_side_effect__single_humidifier, +) + +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_async_get_config_entry_diagnostics__no_devices( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__no_devices + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(diag, dict) + assert diag == snapshot + + +async def test_async_get_config_entry_diagnostics__single_humidifier( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__single_humidifier + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(diag, dict) + assert diag == snapshot + + +async def test_async_get_device_diagnostics__single_fan( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__single_fan + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "abcdefghabcdefghabcdefghabcdefgh")}, + ) + assert device is not None + + diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) + + assert isinstance(diag, dict) + assert diag == snapshot( + matcher=path_type( + { + "home_assistant.entities.0.state.last_changed": (str,), + "home_assistant.entities.0.state.last_updated": (str,), + "home_assistant.entities.1.state.last_changed": (str,), + "home_assistant.entities.1.state.last_updated": (str,), + "home_assistant.entities.2.state.last_changed": (str,), + "home_assistant.entities.2.state.last_updated": (str,), + } + ) + ) diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py new file mode 100644 index 00000000000..0f77c9cbf35 --- /dev/null +++ b/tests/components/vesync/test_init.py @@ -0,0 +1,103 @@ +"""Tests for the init module.""" +from unittest.mock import Mock, patch + +import pytest +from pyvesync import VeSync + +from homeassistant.components.vesync import async_setup_entry +from homeassistant.components.vesync.const import ( + DOMAIN, + VS_FANS, + VS_LIGHTS, + VS_MANAGER, + VS_SENSORS, + VS_SWITCHES, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def test_async_setup_entry__not_login( + hass: HomeAssistant, + config_entry: ConfigEntry, + manager: VeSync, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup does not create config entry when not logged in.""" + manager.login = Mock(return_value=False) + + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock, patch( + "homeassistant.components.vesync.async_process_devices" + ) as process_mock, patch.object( + hass.services, "async_register" + ) as register_mock: + assert not await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + assert setups_mock.call_count == 0 + assert setup_mock.call_count == 0 + assert process_mock.call_count == 0 + assert register_mock.call_count == 0 + + assert manager.login.call_count == 1 + assert DOMAIN not in hass.data + assert "Unable to login to the VeSync server" in caplog.text + + +async def test_async_setup_entry__no_devices( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync +) -> None: + """Test setup connects to vesync and creates empty config when no devices.""" + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock: + assert await async_setup_entry(hass, config_entry) + # Assert platforms loaded + await hass.async_block_till_done() + assert setups_mock.call_count == 1 + assert setups_mock.call_args.args[0] == config_entry + assert setups_mock.call_args.args[1] == [] + assert setup_mock.call_count == 0 + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert not hass.data[DOMAIN][VS_SWITCHES] + assert not hass.data[DOMAIN][VS_FANS] + assert not hass.data[DOMAIN][VS_LIGHTS] + assert not hass.data[DOMAIN][VS_SENSORS] + + +async def test_async_setup_entry__loads_fans( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +) -> None: + """Test setup connects to vesync and loads fan platform.""" + fans = [fan] + manager.fans = fans + manager._dev_list = { + "fans": fans, + } + + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock: + assert await async_setup_entry(hass, config_entry) + # Assert platforms loaded + await hass.async_block_till_done() + assert setups_mock.call_count == 1 + assert setups_mock.call_args.args[0] == config_entry + assert setups_mock.call_args.args[1] == [Platform.FAN, Platform.SENSOR] + assert setup_mock.call_count == 0 + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert not hass.data[DOMAIN][VS_SWITCHES] + assert hass.data[DOMAIN][VS_FANS] == [fan] + assert not hass.data[DOMAIN][VS_LIGHTS] + assert hass.data[DOMAIN][VS_SENSORS] == [fan]