Add diagnostics to VeSync (#86350)
* Add diagnostics to VeSync * Create unit tests for diagnostics and init * Improved diagnostic test coverage * Peer review fixes * Fixed Peer Review comments * Updated based on Peer Review * Additional diagnostic redactions * Removed account_id from diagnostic output
This commit is contained in:
parent
5b49648846
commit
09d0128601
13 changed files with 905 additions and 1 deletions
|
@ -1368,7 +1368,6 @@ omit =
|
||||||
homeassistant/components/verisure/sensor.py
|
homeassistant/components/verisure/sensor.py
|
||||||
homeassistant/components/verisure/switch.py
|
homeassistant/components/verisure/switch.py
|
||||||
homeassistant/components/versasense/*
|
homeassistant/components/versasense/*
|
||||||
homeassistant/components/vesync/__init__.py
|
|
||||||
homeassistant/components/vesync/common.py
|
homeassistant/components/vesync/common.py
|
||||||
homeassistant/components/vesync/fan.py
|
homeassistant/components/vesync/fan.py
|
||||||
homeassistant/components/vesync/light.py
|
homeassistant/components/vesync/light.py
|
||||||
|
|
119
homeassistant/components/vesync/diagnostics.py
Normal file
119
homeassistant/components/vesync/diagnostics.py
Normal file
|
@ -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
|
72
tests/components/vesync/common.py
Normal file
72
tests/components/vesync/common.py
Normal file
|
@ -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}")
|
104
tests/components/vesync/conftest.py
Normal file
104
tests/components/vesync/conftest.py
Normal file
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"traceId": "0000000000",
|
||||||
|
"code": 0,
|
||||||
|
"msg": "request success",
|
||||||
|
"result": {
|
||||||
|
"total": 1,
|
||||||
|
"pageSize": 100,
|
||||||
|
"pageNo": 1,
|
||||||
|
"list": []
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"traceId": "0000000000",
|
||||||
|
"code": 0,
|
||||||
|
"msg": "request success",
|
||||||
|
"result": {
|
||||||
|
"accountID": "9999999",
|
||||||
|
"token": "TOKEN"
|
||||||
|
}
|
||||||
|
}
|
272
tests/components/vesync/snapshots/test_diagnostics.ambr
Normal file
272
tests/components/vesync/snapshots/test_diagnostics.ambr
Normal file
|
@ -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**',
|
||||||
|
})
|
||||||
|
# ---
|
99
tests/components/vesync/test_diagnostics.py
Normal file
99
tests/components/vesync/test_diagnostics.py
Normal file
|
@ -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,),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
103
tests/components/vesync/test_init.py
Normal file
103
tests/components/vesync/test_init.py
Normal file
|
@ -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]
|
Loading…
Add table
Add a link
Reference in a new issue