diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 0993743d461..8e8b7744988 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -13,6 +13,7 @@ from .common import async_process_devices from .const import ( DOMAIN, SERVICE_UPDATE_DEVS, + VS_COORDINATOR, VS_DISCOVERY, VS_FANS, VS_LIGHTS, @@ -20,6 +21,7 @@ from .const import ( VS_SENSORS, VS_SWITCHES, ) +from .coordinator import VeSyncDataCoordinator PLATFORMS = [Platform.FAN, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] @@ -48,6 +50,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager + coordinator = VeSyncDataCoordinator(hass, manager) + + # Store coordinator at domain level since only single integration instance is permitted. + hass.data[DOMAIN][VS_COORDINATOR] = coordinator + switches = hass.data[DOMAIN][VS_SWITCHES] = [] fans = hass.data[DOMAIN][VS_FANS] = [] lights = hass.data[DOMAIN][VS_LIGHTS] = [] diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index b1bad8cfa11..2a8c5722340 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -4,10 +4,25 @@ DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" SERVICE_UPDATE_DEVS = "update_devices" +UPDATE_INTERVAL = 60 +""" +Update interval for DataCoordinator. + +The vesync daily quota formula is 3200 + 1500 * device_count. + +An interval of 60 seconds amounts 1440 calls/day which +would be below the 4700 daily quota. For 2 devices, the +total would be 2880. + +Using 30 seconds interval gives 8640 for 3 devices which +exceeds the quota of 7700. +""" + VS_SWITCHES = "switches" VS_FANS = "fans" VS_LIGHTS = "lights" VS_SENSORS = "sensors" +VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" DEV_TYPE_TO_HA = { diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py new file mode 100644 index 00000000000..f3df2970fdb --- /dev/null +++ b/homeassistant/components/vesync/coordinator.py @@ -0,0 +1,43 @@ +"""Class to manage VeSync data updates.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyvesync import VeSync + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class VeSyncDataCoordinator(DataUpdateCoordinator[None]): + """Class representing data coordinator for VeSync devices.""" + + def __init__(self, hass: HomeAssistant, manager: VeSync) -> None: + """Initialize.""" + self._manager = manager + + super().__init__( + hass, + _LOGGER, + name="VeSyncDataCoordinator", + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + + return await self.hass.async_add_executor_job(self.update_data_all) + + def update_data_all(self) -> None: + """Update all the devices.""" + + # Using `update_all_devices` instead of `update` to avoid fetching device list every time. + self._manager.update_all_devices() + # Vesync updates energy on applicable devices every 6 hours + self._manager.update_energy() diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py index fd636561e9e..68c21a871ab 100644 --- a/homeassistant/components/vesync/entity.py +++ b/homeassistant/components/vesync/entity.py @@ -5,18 +5,23 @@ from typing import Any from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, ToggleEntity +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import VeSyncDataCoordinator -class VeSyncBaseEntity(Entity): +class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator]): """Base class for VeSync Entity Representations.""" _attr_has_entity_name = True - def __init__(self, device: VeSyncBaseDevice) -> None: + def __init__( + self, device: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator + ) -> None: """Initialize the VeSync device.""" + super().__init__(coordinator) self.device = device self._attr_unique_id = self.base_unique_id @@ -46,10 +51,6 @@ class VeSyncBaseEntity(Entity): sw_version=self.device.current_firm_version, ) - def update(self) -> None: - """Update vesync device.""" - self.device.update() - class VeSyncDevice(VeSyncBaseEntity, ToggleEntity): """Base class for VeSync Device Representations.""" diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 5be6a06e1d0..95404a921e8 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -6,6 +6,8 @@ import logging import math from typing import Any +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -17,7 +19,15 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS +from .const import ( + DEV_TYPE_TO_HA, + DOMAIN, + SKU_TO_BASE_DEVICE, + VS_COORDINATOR, + VS_DISCOVERY, + VS_FANS, +) +from .coordinator import VeSyncDataCoordinator from .entity import VeSyncDevice _LOGGER = logging.getLogger(__name__) @@ -56,25 +66,31 @@ async def async_setup_entry( ) -> None: """Set up the VeSync fan platform.""" + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + @callback def discover(devices): """Add new devices to platform.""" - _setup_entities(devices, async_add_entities) + _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_FANS), discover) ) - _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities) + _setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities, coordinator) @callback -def _setup_entities(devices, async_add_entities): +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities, + coordinator: VeSyncDataCoordinator, +): """Check if device is online and add entity.""" entities = [] for dev in devices: - if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type)) == "fan": - entities.append(VeSyncFanHA(dev)) + if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan": + entities.append(VeSyncFanHA(dev, coordinator)) else: _LOGGER.warning( "%s - Unknown device type - %s", dev.device_name, dev.device_type @@ -96,9 +112,9 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): _attr_name = None _attr_translation_key = "vesync" - def __init__(self, fan) -> None: + def __init__(self, fan, coordinator: VeSyncDataCoordinator) -> None: """Initialize the VeSync fan device.""" - super().__init__(fan) + super().__init__(fan, coordinator) self.smartfan = fan @property diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 5b08b92f75a..4deb250bd43 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -3,6 +3,8 @@ import logging from typing import Any +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -15,7 +17,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DISCOVERY, VS_LIGHTS +from .coordinator import VeSyncDataCoordinator from .entity import VeSyncDevice _LOGGER = logging.getLogger(__name__) @@ -30,27 +33,33 @@ async def async_setup_entry( ) -> None: """Set up lights.""" + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + @callback def discover(devices): """Add new devices to platform.""" - _setup_entities(devices, async_add_entities) + _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_LIGHTS), discover) ) - _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities) + _setup_entities(hass.data[DOMAIN][VS_LIGHTS], async_add_entities, coordinator) @callback -def _setup_entities(devices, async_add_entities): +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities, + coordinator: VeSyncDataCoordinator, +): """Check if device is online and add entity.""" - entities = [] + entities: list[VeSyncBaseLight] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"): - entities.append(VeSyncDimmableLightHA(dev)) + entities.append(VeSyncDimmableLightHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",): - entities.append(VeSyncTunableWhiteLightHA(dev)) + entities.append(VeSyncTunableWhiteLightHA(dev, coordinator)) else: _LOGGER.debug( "%s - Unknown device type - %s", dev.device_name, dev.device_type diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 79061ec0c4c..f283e3a3c0a 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass import logging +from pyvesync.vesyncbasedevice import VeSyncBaseDevice from pyvesync.vesyncfan import VeSyncAirBypass from pyvesync.vesyncoutlet import VeSyncOutlet from pyvesync.vesyncswitch import VeSyncSwitch @@ -30,7 +31,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_SENSORS +from .const import ( + DEV_TYPE_TO_HA, + DOMAIN, + SKU_TO_BASE_DEVICE, + VS_COORDINATOR, + VS_DISCOVERY, + VS_SENSORS, +) +from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) @@ -187,24 +196,31 @@ async def async_setup_entry( ) -> None: """Set up switches.""" + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + @callback def discover(devices): """Add new devices to platform.""" - _setup_entities(devices, async_add_entities) + _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SENSORS), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities) + _setup_entities(hass.data[DOMAIN][VS_SENSORS], async_add_entities, coordinator) @callback -def _setup_entities(devices, async_add_entities): +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities, + coordinator: VeSyncDataCoordinator, +): """Check if device is online and add entity.""" + async_add_entities( ( - VeSyncSensorEntity(dev, description) + VeSyncSensorEntity(dev, description, coordinator) for dev in devices for description in SENSORS if description.exists_fn(dev) @@ -222,9 +238,10 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): self, device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch, description: VeSyncSensorEntityDescription, + coordinator, ) -> None: """Initialize the VeSync outlet device.""" - super().__init__(device) + super().__init__(device, coordinator) self.entity_description = description self._attr_unique_id = f"{super().unique_id}-{description.key}" diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index a162a648ad7..eac1d967eee 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -3,13 +3,16 @@ import logging from typing import Any +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_SWITCHES +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DISCOVERY, VS_SWITCHES +from .coordinator import VeSyncDataCoordinator from .entity import VeSyncDevice _LOGGER = logging.getLogger(__name__) @@ -22,27 +25,33 @@ async def async_setup_entry( ) -> None: """Set up switches.""" + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + @callback def discover(devices): """Add new devices to platform.""" - _setup_entities(devices, async_add_entities) + _setup_entities(devices, async_add_entities, coordinator) config_entry.async_on_unload( async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_SWITCHES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities) + _setup_entities(hass.data[DOMAIN][VS_SWITCHES], async_add_entities, coordinator) @callback -def _setup_entities(devices, async_add_entities): +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities, + coordinator: VeSyncDataCoordinator, +): """Check if device is online and add entity.""" - entities = [] + entities: list[VeSyncBaseSwitch] = [] for dev in devices: if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet": - entities.append(VeSyncSwitchHA(dev)) + entities.append(VeSyncSwitchHA(dev, coordinator)) elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch": - entities.append(VeSyncLightSwitch(dev)) + entities.append(VeSyncLightSwitch(dev, coordinator)) else: _LOGGER.warning( "%s - Unknown device type - %s", dev.device_name, dev.device_type @@ -65,21 +74,16 @@ class VeSyncBaseSwitch(VeSyncDevice, SwitchEntity): class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): """Representation of a VeSync switch.""" - def __init__(self, plug): + def __init__(self, plug, coordinator: VeSyncDataCoordinator) -> None: """Initialize the VeSync switch device.""" - super().__init__(plug) + super().__init__(plug, coordinator) self.smartplug = plug - def update(self) -> None: - """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() - class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): """Handle representation of VeSync Light Switch.""" - def __init__(self, switch): + def __init__(self, switch, coordinator: VeSyncDataCoordinator) -> None: """Initialize Light Switch device class.""" - super().__init__(switch) + super().__init__(switch, coordinator) self.switch = switch diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 94e1511ce19..954affb4c1a 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -1,10 +1,12 @@ """Common methods used across tests for VeSync.""" import json +from typing import Any import requests_mock from homeassistant.components.vesync.const import DOMAIN +from homeassistant.util.json import JsonObjectType from tests.common import load_fixture, load_json_object_fixture @@ -26,7 +28,7 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") ], "Air Purifier 400s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-400s-detail.json") ], "Air Purifier 600s": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") @@ -37,7 +39,10 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { "Temperature Light": [ ("post", "/cloud/v1/deviceManaged/bypass", "device-detail.json") ], - "Outlet": [("get", "/v1/device/outlet/detail", "outlet-detail.json")], + "Outlet": [ + ("get", "/v1/device/outlet/detail", "outlet-detail.json"), + ("get", "/v1/device/outlet/energy/week", "outlet-energy-week.json"), + ], "Wall Switch": [ ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") ], @@ -71,6 +76,99 @@ def mock_devices_response( ) +def mock_multiple_device_responses( + requests_mock: requests_mock.Mocker, device_names: list[str] +) -> None: + """Build a response for the Helpers.call_api method for multiple devices.""" + device_list = [ + device + for device in ALL_DEVICES["result"]["list"] + if device["deviceName"] in device_names + ] + + requests_mock.post( + "https://smartapi.vesync.com/cloud/v1/deviceManaged/devices", + json={"code": 0, "result": {"list": device_list}}, + ) + requests_mock.post( + "https://smartapi.vesync.com/cloud/v1/user/login", + json=load_json_object_fixture("vesync-login.json", DOMAIN), + ) + for device_name in device_names: + for fixture in DEVICE_FIXTURES[device_name]: + requests_mock.request( + fixture[0], + f"https://smartapi.vesync.com{fixture[1]}", + json=load_json_object_fixture(fixture[2], DOMAIN), + ) + + +def mock_air_purifier_400s_update_response(requests_mock: requests_mock.Mocker) -> None: + """Build a response for the Helpers.call_api method for air_purifier_400s with updated data.""" + + device_name = "Air Purifier 400s" + for fixture in DEVICE_FIXTURES[device_name]: + requests_mock.request( + fixture[0], + f"https://smartapi.vesync.com{fixture[1]}", + json=load_json_object_fixture( + "air-purifier-400s-detail-updated.json", DOMAIN + ), + ) + + +def mock_device_response( + requests_mock: requests_mock.Mocker, device_name: str, override: Any +) -> None: + """Build a response for the Helpers.call_api method with updated data.""" + + def load_and_merge(source: str) -> JsonObjectType: + json = load_json_object_fixture(source, DOMAIN) + + if override: + json.update(override) + + return json + + fixtures = DEVICE_FIXTURES[device_name] + + # The first item contain basic device details + if len(fixtures) > 0: + item = fixtures[0] + + requests_mock.request( + item[0], + f"https://smartapi.vesync.com{item[1]}", + json=load_and_merge(item[2]), + ) + + +def mock_outlet_energy_response( + requests_mock: requests_mock.Mocker, device_name: str, override: Any +) -> None: + """Build a response for the Helpers.call_api energy request with updated data.""" + + def load_and_merge(source: str) -> JsonObjectType: + json = load_json_object_fixture(source, DOMAIN) + + if override: + json.update(override) + + return json + + fixtures = DEVICE_FIXTURES[device_name] + + # The 2nd item contain energy details + if len(fixtures) > 1: + item = fixtures[1] + + requests_mock.request( + item[0], + f"https://smartapi.vesync.com{item[1]}", + json=load_and_merge(item[2]), + ) + + 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": diff --git a/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json b/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json new file mode 100644 index 00000000000..b48eefba4c9 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json @@ -0,0 +1,39 @@ +{ + "code": 0, + "brightNess": "50", + "result": { + "light": { + "brightness": 50, + "colorTempe": 5400 + }, + "result": { + "brightness": 50, + "red": 178.5, + "green": 255, + "blue": 25.5, + "colorMode": "rgb", + "humidity": 35, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + "enabled": true, + "filter_life": 99, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "on", + "air_quality": 15, + "air_quality_value": 1, + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/fixtures/air-purifier-400s-detail.json b/tests/components/vesync/fixtures/air-purifier-400s-detail.json new file mode 100644 index 00000000000..a26d9e2a975 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-400s-detail.json @@ -0,0 +1,39 @@ +{ + "code": 0, + "brightNess": "50", + "result": { + "light": { + "brightness": 50, + "colorTempe": 5400 + }, + "result": { + "brightness": 50, + "red": 178.5, + "green": 255, + "blue": 25.5, + "colorMode": "rgb", + "humidity": 35, + "mist_virtual_level": 6, + "mode": "manual", + "water_lacks": true, + "water_tank_lifted": true, + "automatic_stop_reach_target": true, + "night_light_brightness": 10, + "enabled": true, + "filter_life": 99, + "level": 1, + "display": true, + "display_forever": false, + "child_lock": false, + "night_light": "off", + "air_quality": 5, + "air_quality_value": 1, + "configuration": { + "auto_target_humidity": 40, + "display": true, + "automatic_stop": true + } + }, + "code": 0 + } +} diff --git a/tests/components/vesync/fixtures/outlet-energy-week.json b/tests/components/vesync/fixtures/outlet-energy-week.json new file mode 100644 index 00000000000..6e23be2e197 --- /dev/null +++ b/tests/components/vesync/fixtures/outlet-energy-week.json @@ -0,0 +1,7 @@ +{ + "energyConsumptionOfToday": 1, + "costPerKWH": 0.15, + "maxEnergy": 6, + "totalEnergy": 0, + "currency": "$" +} diff --git a/tests/components/vesync/test_platform.py b/tests/components/vesync/test_platform.py new file mode 100644 index 00000000000..fa1e24f4628 --- /dev/null +++ b/tests/components/vesync/test_platform.py @@ -0,0 +1,92 @@ +"""Tests for the coordinator.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +import requests_mock + +from homeassistant.components.vesync.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .common import ( + mock_air_purifier_400s_update_response, + mock_device_response, + mock_multiple_device_responses, + mock_outlet_energy_response, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_entity_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + requests_mock: requests_mock.Mocker, +) -> None: + """Test Vesync coordinator data update. + + This test sets up a single device `Air Purifier 400s` and then updates it via the coordinator. + """ + + config_data = {CONF_PASSWORD: "username", CONF_USERNAME: "password"} + config_entry = MockConfigEntry( + data=config_data, + domain=DOMAIN, + unique_id="vesync_unique_id_1", + entry_id="1", + ) + + mock_multiple_device_responses(requests_mock, ["Air Purifier 400s", "Outlet"]) + + expected_entities = [ + # From "Air Purifier 400s" + "fan.air_purifier_400s", + "sensor.air_purifier_400s_filter_lifetime", + "sensor.air_purifier_400s_air_quality", + "sensor.air_purifier_400s_pm2_5", + # From Outlet + "switch.outlet", + "sensor.outlet_current_power", + "sensor.outlet_energy_use_today", + "sensor.outlet_energy_use_weekly", + "sensor.outlet_energy_use_monthly", + "sensor.outlet_energy_use_yearly", + "sensor.outlet_current_voltage", + ] + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + for entity_id in expected_entities: + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "5" + assert hass.states.get("sensor.outlet_current_voltage").state == "120.0" + assert hass.states.get("sensor.outlet_energy_use_weekly").state == "0" + + # Update the mock responses + mock_air_purifier_400s_update_response(requests_mock) + mock_outlet_energy_response(requests_mock, "Outlet", {"totalEnergy": 2.2}) + mock_device_response(requests_mock, "Outlet", {"voltage": 129}) + + freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done(True) + + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "15" + assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" + + # Test energy update + # pyvesync only updates energy parameters once every 6 hours. + freezer.tick(timedelta(hours=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done(True) + + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "15" + assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" + assert hass.states.get("sensor.outlet_energy_use_weekly").state == "2.2"