From 4290a1fcb5675c618a71093222b232a39239f95f Mon Sep 17 00:00:00 2001 From: Teemu R <tpr@iki.fi> Date: Tue, 25 Jun 2024 22:01:21 +0200 Subject: [PATCH] Upgrade tplink with new platforms, features and device support (#120060) Co-authored-by: Teemu Rytilahti <tpr@iki.fi> Co-authored-by: sdb9696 <steven.beth@gmail.com> Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: Teemu R. <tpr@iki.fi> --- homeassistant/components/tplink/README.md | 34 + homeassistant/components/tplink/__init__.py | 125 ++- .../components/tplink/binary_sensor.py | 96 +++ homeassistant/components/tplink/button.py | 69 ++ homeassistant/components/tplink/climate.py | 140 ++++ .../components/tplink/config_flow.py | 56 +- homeassistant/components/tplink/const.py | 22 +- .../components/tplink/coordinator.py | 8 +- .../components/tplink/diagnostics.py | 12 +- homeassistant/components/tplink/entity.py | 375 ++++++++- homeassistant/components/tplink/fan.py | 111 +++ homeassistant/components/tplink/icons.json | 99 +++ homeassistant/components/tplink/light.py | 190 +++-- homeassistant/components/tplink/manifest.json | 30 +- homeassistant/components/tplink/number.py | 108 +++ homeassistant/components/tplink/select.py | 95 +++ homeassistant/components/tplink/sensor.py | 229 +++-- homeassistant/components/tplink/strings.json | 140 +++- homeassistant/components/tplink/switch.py | 182 ++-- homeassistant/generated/dhcp.py | 35 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/__init__.py | 514 +++++++----- tests/components/tplink/conftest.py | 12 +- .../components/tplink/fixtures/features.json | 287 +++++++ .../tplink/snapshots/test_binary_sensor.ambr | 369 ++++++++ .../tplink/snapshots/test_button.ambr | 127 +++ .../tplink/snapshots/test_climate.ambr | 94 +++ .../components/tplink/snapshots/test_fan.ambr | 194 +++++ .../tplink/snapshots/test_number.ambr | 255 ++++++ .../tplink/snapshots/test_select.ambr | 238 ++++++ .../tplink/snapshots/test_sensor.ambr | 790 ++++++++++++++++++ .../tplink/snapshots/test_switch.ambr | 311 +++++++ tests/components/tplink/test_binary_sensor.py | 124 +++ tests/components/tplink/test_button.py | 153 ++++ tests/components/tplink/test_climate.py | 226 +++++ tests/components/tplink/test_config_flow.py | 54 +- tests/components/tplink/test_diagnostics.py | 10 +- tests/components/tplink/test_fan.py | 154 ++++ tests/components/tplink/test_init.py | 190 ++++- tests/components/tplink/test_light.py | 429 ++++++---- tests/components/tplink/test_number.py | 163 ++++ tests/components/tplink/test_select.py | 158 ++++ tests/components/tplink/test_sensor.py | 233 +++++- tests/components/tplink/test_switch.py | 160 +++- 45 files changed, 6542 insertions(+), 863 deletions(-) create mode 100644 homeassistant/components/tplink/README.md create mode 100644 homeassistant/components/tplink/binary_sensor.py create mode 100644 homeassistant/components/tplink/button.py create mode 100644 homeassistant/components/tplink/climate.py create mode 100644 homeassistant/components/tplink/fan.py create mode 100644 homeassistant/components/tplink/number.py create mode 100644 homeassistant/components/tplink/select.py create mode 100644 tests/components/tplink/fixtures/features.json create mode 100644 tests/components/tplink/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/tplink/snapshots/test_button.ambr create mode 100644 tests/components/tplink/snapshots/test_climate.ambr create mode 100644 tests/components/tplink/snapshots/test_fan.ambr create mode 100644 tests/components/tplink/snapshots/test_number.ambr create mode 100644 tests/components/tplink/snapshots/test_select.ambr create mode 100644 tests/components/tplink/snapshots/test_sensor.ambr create mode 100644 tests/components/tplink/snapshots/test_switch.ambr create mode 100644 tests/components/tplink/test_binary_sensor.py create mode 100644 tests/components/tplink/test_button.py create mode 100644 tests/components/tplink/test_climate.py create mode 100644 tests/components/tplink/test_fan.py create mode 100644 tests/components/tplink/test_number.py create mode 100644 tests/components/tplink/test_select.py diff --git a/homeassistant/components/tplink/README.md b/homeassistant/components/tplink/README.md new file mode 100644 index 00000000000..129d9e7fcce --- /dev/null +++ b/homeassistant/components/tplink/README.md @@ -0,0 +1,34 @@ +# TPLink Integration + +This document covers details that new contributors may find helpful when getting started. + +## Modules vs Features + +The python-kasa library which this integration depends on exposes functionality via modules and features. +The `Module` APIs encapsulate groups of functionality provided by a device, +e.g. Light which has multiple attributes and methods such as `set_hsv`, `brightness` etc. +The `features` encapsulate unitary functions and allow for introspection. +e.g. `on_since`, `voltage` etc. + +If the integration implements a platform that presents single functions or data points, such as `sensor`, +`button`, `switch` it uses features. +If it's implementing a platform with more complex functionality like `light`, `fan` or `climate` it will +use modules. + +## Adding new entities + +All feature-based entities are created based on the information from the upstream library. +If you want to add new feature, it needs to be implemented at first in there. +After the feature is exposed by the upstream library, +it needs to be added to the `<PLATFORM>_DESCRIPTIONS` list of the corresponding platform. +The integration logs missing descriptions on features supported by the device to help spotting them. + +In many cases it is enough to define the `key` (corresponding to upstream `feature.id`), +but you can pass more information for nicer user experience: +* `device_class` and `state_class` should be set accordingly for binary_sensor and sensor +* If no matching classes are available, you need to update `strings.json` and `icons.json` +When doing so, do not forget to run `script/setup` to generate the translations. + +Other information like the category and whether to enable per default are read from the feature, +as are information about units and display precision hints. + diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index fbb176b2d5f..764867f0bee 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -9,14 +9,15 @@ from typing import Any from aiohttp import ClientSession from kasa import ( - AuthenticationException, + AuthenticationError, Credentials, + Device, DeviceConfig, Discover, - SmartDevice, - SmartDeviceException, + KasaException, ) from kasa.httpclient import get_cookie_jar +from kasa.iot import IotStrip from homeassistant import config_entries from homeassistant.components import network @@ -51,6 +52,8 @@ from .const import ( from .coordinator import TPLinkDataUpdateCoordinator from .models import TPLinkData +type TPLinkConfigEntry = ConfigEntry[TPLinkData] + DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -67,7 +70,7 @@ def create_async_tplink_clientsession(hass: HomeAssistant) -> ClientSession: @callback def async_trigger_discovery( hass: HomeAssistant, - discovered_devices: dict[str, SmartDevice], + discovered_devices: dict[str, Device], ) -> None: """Trigger config flows for discovered devices.""" for formatted_mac, device in discovered_devices.items(): @@ -87,7 +90,7 @@ def async_trigger_discovery( ) -async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: +async def async_discover_devices(hass: HomeAssistant) -> dict[str, Device]: """Discover TPLink devices on configured network interfaces.""" credentials = await get_credentials(hass) @@ -101,7 +104,7 @@ async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: ) for address in broadcast_addresses ] - discovered_devices: dict[str, SmartDevice] = {} + discovered_devices: dict[str, Device] = {} for device_list in await asyncio.gather(*tasks): for device in device_list.values(): discovered_devices[dr.format_mac(device.mac)] = device @@ -126,7 +129,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bool: """Set up TPLink from a config entry.""" host: str = entry.data[CONF_HOST] credentials = await get_credentials(hass) @@ -135,7 +138,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if config_dict := entry.data.get(CONF_DEVICE_CONFIG): try: config = DeviceConfig.from_dict(config_dict) - except SmartDeviceException: + except KasaException: _LOGGER.warning( "Invalid connection type dict for %s: %s", host, config_dict ) @@ -151,10 +154,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if credentials: config.credentials = credentials try: - device: SmartDevice = await SmartDevice.connect(config=config) - except AuthenticationException as ex: + device: Device = await Device.connect(config=config) + except AuthenticationError as ex: raise ConfigEntryAuthFailed from ex - except SmartDeviceException as ex: + except KasaException as ex: raise ConfigEntryNotReady from ex device_config_dict = device.config.to_dict( @@ -189,7 +192,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5)) child_coordinators: list[TPLinkDataUpdateCoordinator] = [] - if device.is_strip: + # The iot HS300 allows a limited number of concurrent requests and fetching the + # emeter information requires separate ones so create child coordinators here. + if isinstance(device, IotStrip): child_coordinators = [ # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device @@ -197,27 +202,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for child in device.children ] - hass.data[DOMAIN][entry.entry_id] = TPLinkData( - parent_coordinator, child_coordinators - ) + entry.runtime_data = TPLinkData(parent_coordinator, child_coordinators) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bool: """Unload a config entry.""" - hass_data: dict[str, Any] = hass.data[DOMAIN] - data: TPLinkData = hass_data[entry.entry_id] + data = entry.runtime_data device = data.parent_coordinator.device - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass_data.pop(entry.entry_id) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) await device.protocol.close() return unload_ok -def legacy_device_id(device: SmartDevice) -> str: +def legacy_device_id(device: Device) -> str: """Convert the device id so it matches what was used in the original version.""" device_id: str = device.device_id # Plugs are prefixed with the mac in python-kasa but not @@ -227,6 +228,24 @@ def legacy_device_id(device: SmartDevice) -> str: return device_id.split("_")[1] +def get_device_name(device: Device, parent: Device | None = None) -> str: + """Get a name for the device. alias can be none on some devices.""" + if device.alias: + return device.alias + # Return the child device type with an index if there's more than one child device + # of the same type. i.e. Devices like the ks240 with one child of each type + # skip the suffix + if parent: + devices = [ + child.device_id + for child in parent.children + if child.device_type is device.device_type + ] + suffix = f" {devices.index(device.device_id) + 1}" if len(devices) > 1 else "" + return f"{device.device_type.value.capitalize()}{suffix}" + return f"Unnamed {device.model}" + + async def get_credentials(hass: HomeAssistant) -> Credentials | None: """Retrieve the credentials from hass data.""" if DOMAIN in hass.data and CONF_AUTHENTICATION in hass.data[DOMAIN]: @@ -247,3 +266,67 @@ async def set_credentials(hass: HomeAssistant, username: str, password: str) -> def mac_alias(mac: str) -> str: """Convert a MAC address to a short address for the UI.""" return mac.replace(":", "")[-4:].upper() + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + version = config_entry.version + minor_version = config_entry.minor_version + + _LOGGER.debug("Migrating from version %s.%s", version, minor_version) + + if version == 1 and minor_version < 3: + # Previously entities on child devices added themselves to the parent + # device and set their device id as identifiers along with mac + # as a connection which creates a single device entry linked by all + # identifiers. Now we create separate devices connected with via_device + # so the identifier linkage must be removed otherwise the devices will + # always be linked into one device. + dev_reg = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id): + new_identifiers: set[tuple[str, str]] | None = None + if len(device.identifiers) > 1 and ( + mac := next( + iter( + [ + conn[1] + for conn in device.connections + if conn[0] == dr.CONNECTION_NETWORK_MAC + ] + ), + None, + ) + ): + for identifier in device.identifiers: + # Previously only iot devices that use the MAC address as + # device_id had child devices so check for mac as the + # parent device. + if identifier[0] == DOMAIN and identifier[1].upper() == mac.upper(): + new_identifiers = {identifier} + break + if new_identifiers: + dev_reg.async_update_device( + device.id, new_identifiers=new_identifiers + ) + _LOGGER.debug( + "Replaced identifiers for device %s (%s): %s with: %s", + device.name, + device.model, + device.identifiers, + new_identifiers, + ) + else: + # No match on mac so raise an error. + _LOGGER.error( + "Unable to replace identifiers for device %s (%s): %s", + device.name, + device.model, + device.identifiers, + ) + + minor_version = 3 + hass.config_entries.async_update_entry(config_entry, minor_version=3) + + _LOGGER.debug("Migration to version %s.%s successful", version, minor_version) + + return True diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py new file mode 100644 index 00000000000..97bb794a8f9 --- /dev/null +++ b/homeassistant/components/tplink/binary_sensor.py @@ -0,0 +1,96 @@ +"""Support for TPLink binary sensors.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from kasa import Feature + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class TPLinkBinarySensorEntityDescription( + BinarySensorEntityDescription, TPLinkFeatureEntityDescription +): + """Base class for a TPLink feature based sensor entity description.""" + + +BINARY_SENSOR_DESCRIPTIONS: Final = ( + TPLinkBinarySensorEntityDescription( + key="overheated", + device_class=BinarySensorDeviceClass.PROBLEM, + ), + TPLinkBinarySensorEntityDescription( + key="battery_low", + device_class=BinarySensorDeviceClass.BATTERY, + ), + TPLinkBinarySensorEntityDescription( + key="cloud_connection", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + # To be replaced & disabled per default by the upcoming update platform. + TPLinkBinarySensorEntityDescription( + key="update_available", + device_class=BinarySensorDeviceClass.UPDATE, + ), + TPLinkBinarySensorEntityDescription( + key="temperature_warning", + ), + TPLinkBinarySensorEntityDescription( + key="humidity_warning", + ), + TPLinkBinarySensorEntityDescription( + key="is_open", + device_class=BinarySensorDeviceClass.DOOR, + ), + TPLinkBinarySensorEntityDescription( + key="water_alert", + device_class=BinarySensorDeviceClass.MOISTURE, + ), +) + +BINARYSENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in BINARY_SENSOR_DESCRIPTIONS} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + children_coordinators = data.children_coordinators + device = parent_coordinator.device + + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.BinarySensor, + entity_class=TPLinkBinarySensorEntity, + descriptions=BINARYSENSOR_DESCRIPTIONS_MAP, + child_coordinators=children_coordinators, + ) + async_add_entities(entities) + + +class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntity): + """Representation of a TPLink binary sensor.""" + + entity_description: TPLinkBinarySensorEntityDescription + + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_is_on = self._feature.value diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py new file mode 100644 index 00000000000..4dcc27858a8 --- /dev/null +++ b/homeassistant/components/tplink/button.py @@ -0,0 +1,69 @@ +"""Support for TPLink button entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from kasa import Feature + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class TPLinkButtonEntityDescription( + ButtonEntityDescription, TPLinkFeatureEntityDescription +): + """Base class for a TPLink feature based button entity description.""" + + +BUTTON_DESCRIPTIONS: Final = [ + TPLinkButtonEntityDescription( + key="test_alarm", + ), + TPLinkButtonEntityDescription( + key="stop_alarm", + ), +] + +BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up buttons.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + children_coordinators = data.children_coordinators + device = parent_coordinator.device + + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Action, + entity_class=TPLinkButtonEntity, + descriptions=BUTTON_DESCRIPTIONS_MAP, + child_coordinators=children_coordinators, + ) + async_add_entities(entities) + + +class TPLinkButtonEntity(CoordinatedTPLinkFeatureEntity, ButtonEntity): + """Representation of a TPLink button entity.""" + + entity_description: TPLinkButtonEntityDescription + + async def async_press(self) -> None: + """Execute action.""" + await self._feature.set_value(True) + + def _async_update_attrs(self) -> None: + """No need to update anything.""" diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py new file mode 100644 index 00000000000..99a8c43fac3 --- /dev/null +++ b/homeassistant/components/tplink/climate.py @@ -0,0 +1,140 @@ +"""Support for TP-Link thermostats.""" + +from __future__ import annotations + +import logging +from typing import Any, cast + +from kasa import Device, DeviceType +from kasa.smart.modules.temperaturecontrol import ThermostatState + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import PRECISION_WHOLE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .const import UNIT_MAPPING +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity, async_refresh_after + +# Upstream state to HVACAction +STATE_TO_ACTION = { + ThermostatState.Idle: HVACAction.IDLE, + ThermostatState.Heating: HVACAction.HEATING, + ThermostatState.Off: HVACAction.OFF, +} + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate entities.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + + # As there are no standalone thermostats, we just iterate over the children. + async_add_entities( + TPLinkClimateEntity(child, parent_coordinator, parent=device) + for child in device.children + if child.device_type is DeviceType.Thermostat + ) + + +class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity): + """Representation of a TPLink thermostat.""" + + _attr_name = None + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_precision = PRECISION_WHOLE + + # This disables the warning for async_turn_{on,off}, can be removed later. + _enable_turn_on_off_backwards_compatibility = False + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + parent: Device, + ) -> None: + """Initialize the climate entity.""" + super().__init__(device, coordinator, parent=parent) + self._state_feature = self._device.features["state"] + self._mode_feature = self._device.features["thermostat_mode"] + self._temp_feature = self._device.features["temperature"] + self._target_feature = self._device.features["target_temperature"] + + self._attr_min_temp = self._target_feature.minimum_value + self._attr_max_temp = self._target_feature.maximum_value + self._attr_temperature_unit = UNIT_MAPPING[cast(str, self._temp_feature.unit)] + + @async_refresh_after + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature.""" + await self._target_feature.set_value(int(kwargs[ATTR_TEMPERATURE])) + + @async_refresh_after + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode (heat/off).""" + if hvac_mode is HVACMode.HEAT: + await self._state_feature.set_value(True) + elif hvac_mode is HVACMode.OFF: + await self._state_feature.set_value(False) + else: + raise ServiceValidationError(f"Tried to set unsupported mode: {hvac_mode}") + + @async_refresh_after + async def async_turn_on(self) -> None: + """Turn heating on.""" + await self._state_feature.set_value(True) + + @async_refresh_after + async def async_turn_off(self) -> None: + """Turn heating off.""" + await self._state_feature.set_value(False) + + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_current_temperature = self._temp_feature.value + self._attr_target_temperature = self._target_feature.value + + self._attr_hvac_mode = ( + HVACMode.HEAT if self._state_feature.value else HVACMode.OFF + ) + + if ( + self._mode_feature.value not in STATE_TO_ACTION + and self._attr_hvac_action is not HVACAction.OFF + ): + _LOGGER.warning( + "Unknown thermostat state, defaulting to OFF: %s", + self._mode_feature.value, + ) + self._attr_hvac_action = HVACAction.OFF + return + + self._attr_hvac_action = STATE_TO_ACTION[self._mode_feature.value] + + def _get_unique_id(self) -> str: + """Return unique id.""" + return f"{self._device.device_id}_climate" diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index df3291561fa..7bead2207a3 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -6,13 +6,13 @@ from collections.abc import Mapping from typing import Any from kasa import ( - AuthenticationException, + AuthenticationError, Credentials, + Device, DeviceConfig, Discover, - SmartDevice, - SmartDeviceException, - TimeoutException, + KasaException, + TimeoutError, ) import voluptuous as vol @@ -55,13 +55,13 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for tplink.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 reauth_entry: ConfigEntry | None = None def __init__(self) -> None: """Initialize the config flow.""" - self._discovered_devices: dict[str, SmartDevice] = {} - self._discovered_device: SmartDevice | None = None + self._discovered_devices: dict[str, Device] = {} + self._discovered_device: Device | None = None async def async_step_dhcp( self, discovery_info: dhcp.DhcpServiceInfo @@ -129,9 +129,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): await self._async_try_discover_and_update( host, credentials, raise_on_progress=True ) - except AuthenticationException: + except AuthenticationError: return await self.async_step_discovery_auth_confirm() - except SmartDeviceException: + except KasaException: return self.async_abort(reason="cannot_connect") return await self.async_step_discovery_confirm() @@ -149,7 +149,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._async_try_connect( self._discovered_device, credentials ) - except AuthenticationException: + except AuthenticationError: pass # Authentication exceptions should continue to the rest of the step else: self._discovered_device = device @@ -165,10 +165,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._async_try_connect( self._discovered_device, credentials ) - except AuthenticationException as ex: + except AuthenticationError as ex: errors[CONF_PASSWORD] = "invalid_auth" placeholders["error"] = str(ex) - except SmartDeviceException as ex: + except KasaException as ex: errors["base"] = "cannot_connect" placeholders["error"] = str(ex) else: @@ -229,9 +229,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._async_try_discover_and_update( host, credentials, raise_on_progress=False ) - except AuthenticationException: + except AuthenticationError: return await self.async_step_user_auth_confirm() - except SmartDeviceException as ex: + except KasaException as ex: errors["base"] = "cannot_connect" placeholders["error"] = str(ex) else: @@ -261,10 +261,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._async_try_connect( self._discovered_device, credentials ) - except AuthenticationException as ex: + except AuthenticationError as ex: errors[CONF_PASSWORD] = "invalid_auth" placeholders["error"] = str(ex) - except SmartDeviceException as ex: + except KasaException as ex: errors["base"] = "cannot_connect" placeholders["error"] = str(ex) else: @@ -298,9 +298,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._async_try_connect( self._discovered_device, credentials ) - except AuthenticationException: + except AuthenticationError: return await self.async_step_user_auth_confirm() - except SmartDeviceException: + except KasaException: return self.async_abort(reason="cannot_connect") return self._async_create_entry_from_device(device) @@ -343,7 +343,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): _config_entries.flow.async_abort(flow["flow_id"]) @callback - def _async_create_entry_from_device(self, device: SmartDevice) -> ConfigFlowResult: + def _async_create_entry_from_device(self, device: Device) -> ConfigFlowResult: """Create a config entry from a smart device.""" self._abort_if_unique_id_configured(updates={CONF_HOST: device.host}) return self.async_create_entry( @@ -364,7 +364,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): host: str, credentials: Credentials | None, raise_on_progress: bool, - ) -> SmartDevice: + ) -> Device: """Try to discover the device and call update. Will try to connect to legacy devices if discovery fails. @@ -373,11 +373,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device = await Discover.discover_single( host, credentials=credentials ) - except TimeoutException: + except TimeoutError: # Try connect() to legacy devices if discovery fails - self._discovered_device = await SmartDevice.connect( - config=DeviceConfig(host) - ) + self._discovered_device = await Device.connect(config=DeviceConfig(host)) else: if self._discovered_device.config.uses_http: self._discovered_device.config.http_client = ( @@ -392,9 +390,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_try_connect( self, - discovered_device: SmartDevice, + discovered_device: Device, credentials: Credentials | None, - ) -> SmartDevice: + ) -> Device: """Try to connect.""" self._async_abort_entries_match({CONF_HOST: discovered_device.host}) @@ -405,7 +403,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): if config.uses_http: config.http_client = create_async_tplink_clientsession(self.hass) - self._discovered_device = await SmartDevice.connect(config=config) + self._discovered_device = await Device.connect(config=config) await self.async_set_unique_id( dr.format_mac(self._discovered_device.mac), raise_on_progress=False, @@ -442,10 +440,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): credentials=credentials, raise_on_progress=True, ) - except AuthenticationException as ex: + except AuthenticationError as ex: errors[CONF_PASSWORD] = "invalid_auth" placeholders["error"] = str(ex) - except SmartDeviceException as ex: + except KasaException as ex: errors["base"] = "cannot_connect" placeholders["error"] = str(ex) else: diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 96892bacee7..d77d415aa9c 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -4,13 +4,16 @@ from __future__ import annotations from typing import Final -from homeassistant.const import Platform +from homeassistant.const import Platform, UnitOfTemperature DOMAIN = "tplink" DISCOVERY_TIMEOUT = 5 # Home Assistant will complain if startup takes > 10s CONNECT_TIMEOUT = 5 +# Identifier used for primary control state. +PRIMARY_STATE_ID = "state" + ATTR_CURRENT_A: Final = "current_a" ATTR_CURRENT_POWER_W: Final = "current_power_w" ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" @@ -18,4 +21,19 @@ ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" CONF_DEVICE_CONFIG: Final = "device_config" -PLATFORMS: Final = [Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: Final = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.FAN, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] + +UNIT_MAPPING = { + "celsius": UnitOfTemperature.CELSIUS, + "fahrenheit": UnitOfTemperature.FAHRENHEIT, +} diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 7595cdd8f90..1c362d33746 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import timedelta import logging -from kasa import AuthenticationException, SmartDevice, SmartDeviceException +from kasa import AuthenticationError, Device, KasaException from homeassistant import config_entries from homeassistant.core import HomeAssistant @@ -26,7 +26,7 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, - device: SmartDevice, + device: Device, update_interval: timedelta, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" @@ -47,7 +47,7 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): """Fetch all device and sensor data from api.""" try: await self.device.update(update_children=False) - except AuthenticationException as ex: + except AuthenticationError as ex: raise ConfigEntryAuthFailed from ex - except SmartDeviceException as ex: + except KasaException as ex: raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/tplink/diagnostics.py b/homeassistant/components/tplink/diagnostics.py index e5e84b48162..46a5f0cb1bd 100644 --- a/homeassistant/components/tplink/diagnostics.py +++ b/homeassistant/components/tplink/diagnostics.py @@ -5,12 +5,10 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac -from .const import DOMAIN -from .models import TPLinkData +from . import TPLinkConfigEntry TO_REDACT = { # Entry fields @@ -23,6 +21,7 @@ TO_REDACT = { "hwId", "oemId", "deviceId", + "id", # child id for HS300 # Device location "latitude", "latitude_i", @@ -38,14 +37,17 @@ TO_REDACT = { "ssid", "nickname", "ip", + # Child device information + "original_device_id", + "parent_device_id", } async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TPLinkConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: TPLinkData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data coordinator = data.parent_coordinator oui = format_mac(coordinator.device.mac)[:8].upper() return async_redact_data( diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 52b226a1c57..4e8ec0e0779 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -2,24 +2,81 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Coroutine, Mapping +from dataclasses import dataclass, replace +import logging from typing import Any, Concatenate from kasa import ( - AuthenticationException, - SmartDevice, - SmartDeviceException, - TimeoutException, + AuthenticationError, + Device, + DeviceType, + Feature, + KasaException, + TimeoutError, ) +from homeassistant.const import EntityCategory +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from . import get_device_name, legacy_device_id +from .const import ( + ATTR_CURRENT_A, + ATTR_CURRENT_POWER_W, + ATTR_TODAY_ENERGY_KWH, + ATTR_TOTAL_ENERGY_KWH, + DOMAIN, + PRIMARY_STATE_ID, +) from .coordinator import TPLinkDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + +# Mapping from upstream category to homeassistant category +FEATURE_CATEGORY_TO_ENTITY_CATEGORY = { + Feature.Category.Config: EntityCategory.CONFIG, + Feature.Category.Info: EntityCategory.DIAGNOSTIC, + Feature.Category.Debug: EntityCategory.DIAGNOSTIC, +} + +# Skips creating entities for primary features supported by a specialized platform. +# For example, we do not need a separate "state" switch for light bulbs. +DEVICETYPES_WITH_SPECIALIZED_PLATFORMS = { + DeviceType.Bulb, + DeviceType.LightStrip, + DeviceType.Dimmer, + DeviceType.Fan, + DeviceType.Thermostat, +} + +# Features excluded due to future platform additions +EXCLUDED_FEATURES = { + # update + "current_firmware_version", + "available_firmware_version", + # fan + "fan_speed_level", +} + +LEGACY_KEY_MAPPING = { + "current": ATTR_CURRENT_A, + "current_consumption": ATTR_CURRENT_POWER_W, + "consumption_today": ATTR_TODAY_ENERGY_KWH, + "consumption_total": ATTR_TOTAL_ENERGY_KWH, +} + + +@dataclass(frozen=True, kw_only=True) +class TPLinkFeatureEntityDescription(EntityDescription): + """Base class for a TPLink feature based entity description.""" + def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( func: Callable[Concatenate[_T, _P], Awaitable[None]], @@ -29,7 +86,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) - except AuthenticationException as ex: + except AuthenticationError as ex: self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( translation_domain=DOMAIN, @@ -39,7 +96,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - except TimeoutException as ex: + except TimeoutError as ex: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="device_timeout", @@ -48,7 +105,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - except SmartDeviceException as ex: + except KasaException as ex: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="device_error", @@ -62,24 +119,302 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( return _async_wrap -class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): +class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator], ABC): """Common base class for all coordinated tplink entities.""" _attr_has_entity_name = True + _device: Device def __init__( - self, device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + feature: Feature | None = None, + parent: Device | None = None, ) -> None: - """Initialize the switch.""" + """Initialize the entity.""" super().__init__(coordinator) - self.device: SmartDevice = device - self._attr_unique_id = device.device_id + self._device: Device = device + self._feature = feature + + registry_device = device + device_name = get_device_name(device, parent=parent) + if parent and parent.device_type is not Device.Type.Hub: + if not feature or feature.id == PRIMARY_STATE_ID: + # Entity will be added to parent if not a hub and no feature + # parameter (i.e. core platform like Light, Fan) or the feature + # is the primary state + registry_device = parent + device_name = get_device_name(registry_device) + else: + # Prefix the device name with the parent name unless it is a + # hub attached device. Sensible default for child devices like + # strip plugs or the ks240 where the child alias makes more + # sense in the context of the parent. i.e. Hall Ceiling Fan & + # Bedroom Ceiling Fan; Child device aliases will be Ceiling Fan + # and Dimmer Switch for both so should be distinguished by the + # parent name. + device_name = f"{get_device_name(parent)} {get_device_name(device, parent=parent)}" + self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, - identifiers={(DOMAIN, str(device.device_id))}, + identifiers={(DOMAIN, str(registry_device.device_id))}, manufacturer="TP-Link", - model=device.model, - name=device.alias, - sw_version=device.hw_info["sw_ver"], - hw_version=device.hw_info["hw_ver"], + model=registry_device.model, + name=device_name, + sw_version=registry_device.hw_info["sw_ver"], + hw_version=registry_device.hw_info["hw_ver"], + ) + + if ( + parent is not None + and parent != registry_device + and parent.device_type is not Device.Type.WallSwitch + ): + self._attr_device_info["via_device"] = (DOMAIN, parent.device_id) + else: + self._attr_device_info["connections"] = { + (dr.CONNECTION_NETWORK_MAC, device.mac) + } + + self._attr_unique_id = self._get_unique_id() + + def _get_unique_id(self) -> str: + """Return unique ID for the entity.""" + return legacy_device_id(self._device) + + async def async_added_to_hass(self) -> None: + """Handle being added to hass.""" + self._async_call_update_attrs() + return await super().async_added_to_hass() + + @abstractmethod + @callback + def _async_update_attrs(self) -> None: + """Platforms implement this to update the entity internals.""" + raise NotImplementedError + + @callback + def _async_call_update_attrs(self) -> None: + """Call update_attrs and make entity unavailable on error. + + update_attrs can sometimes fail if a device firmware update breaks the + downstream library. + """ + try: + self._async_update_attrs() + except Exception as ex: # noqa: BLE001 + if self._attr_available: + _LOGGER.warning( + "Unable to read data for %s %s: %s", + self._device, + self.entity_id, + ex, + ) + self._attr_available = False + else: + self._attr_available = True + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_call_update_attrs() + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and self._attr_available + + +class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): + """Common base class for all coordinated tplink feature entities.""" + + entity_description: TPLinkFeatureEntityDescription + _feature: Feature + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + feature: Feature, + description: TPLinkFeatureEntityDescription, + parent: Device | None = None, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(device, coordinator, parent=parent, feature=feature) + + def _get_unique_id(self) -> str: + """Return unique ID for the entity.""" + key = self.entity_description.key + # The unique id for the state feature in the switch platform is the + # device_id + if key == PRIMARY_STATE_ID: + return legacy_device_id(self._device) + + # Historically the legacy device emeter attributes which are now + # replaced with features used slightly different keys. This ensures + # that those entities are not orphaned. Returns the mapped key or the + # provided key if not mapped. + key = LEGACY_KEY_MAPPING.get(key, key) + return f"{legacy_device_id(self._device)}_{key}" + + @classmethod + def _category_for_feature(cls, feature: Feature | None) -> EntityCategory | None: + """Return entity category for a feature.""" + # Main controls have no category + if feature is None or feature.category is Feature.Category.Primary: + return None + + if ( + entity_category := FEATURE_CATEGORY_TO_ENTITY_CATEGORY.get(feature.category) + ) is None: + _LOGGER.error( + "Unhandled category %s, fallback to DIAGNOSTIC", feature.category + ) + entity_category = EntityCategory.DIAGNOSTIC + + return entity_category + + @classmethod + def _description_for_feature[_D: EntityDescription]( + cls, + feature: Feature, + descriptions: Mapping[str, _D], + *, + device: Device, + parent: Device | None = None, + ) -> _D | None: + """Return description object for the given feature. + + This is responsible for setting the common parameters & deciding + based on feature id which additional parameters are passed. + """ + + if descriptions and (desc := descriptions.get(feature.id)): + translation_key: str | None = feature.id + # HA logic is to name entities based on the following logic: + # _attr_name > translation.name > description.name + # > device_class (if base platform supports). + name: str | None | UndefinedType = UNDEFINED + + # The state feature gets the device name or the child device + # name if it's a child device + if feature.id == PRIMARY_STATE_ID: + translation_key = None + # if None will use device name + name = get_device_name(device, parent=parent) if parent else None + + return replace( + desc, + translation_key=translation_key, + name=name, # if undefined will use translation key + entity_category=cls._category_for_feature(feature), + # enabled_default can be overridden to False in the description + entity_registry_enabled_default=feature.category + is not Feature.Category.Debug + and desc.entity_registry_enabled_default, + ) + + _LOGGER.info( + "Device feature: %s (%s) needs an entity description defined in HA", + feature.name, + feature.id, ) + return None + + @classmethod + def _entities_for_device[ + _E: CoordinatedTPLinkFeatureEntity, + _D: TPLinkFeatureEntityDescription, + ]( + cls, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + feature_type: Feature.Type, + entity_class: type[_E], + descriptions: Mapping[str, _D], + parent: Device | None = None, + ) -> list[_E]: + """Return a list of entities to add. + + This filters out unwanted features to avoid creating unnecessary entities + for device features that are implemented by specialized platforms like light. + """ + entities: list[_E] = [ + entity_class( + device, + coordinator, + feature=feat, + description=desc, + parent=parent, + ) + for feat in device.features.values() + if feat.type == feature_type + and feat.id not in EXCLUDED_FEATURES + and ( + feat.category is not Feature.Category.Primary + or device.device_type not in DEVICETYPES_WITH_SPECIALIZED_PLATFORMS + ) + and ( + desc := cls._description_for_feature( + feat, descriptions, device=device, parent=parent + ) + ) + ] + return entities + + @classmethod + def entities_for_device_and_its_children[ + _E: CoordinatedTPLinkFeatureEntity, + _D: TPLinkFeatureEntityDescription, + ]( + cls, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + feature_type: Feature.Type, + entity_class: type[_E], + descriptions: Mapping[str, _D], + child_coordinators: list[TPLinkDataUpdateCoordinator] | None = None, + ) -> list[_E]: + """Create entities for device and its children. + + This is a helper that calls *_entities_for_device* for the device and its children. + """ + entities: list[_E] = [] + # Add parent entities before children so via_device id works. + entities.extend( + cls._entities_for_device( + device, + coordinator=coordinator, + feature_type=feature_type, + entity_class=entity_class, + descriptions=descriptions, + ) + ) + if device.children: + _LOGGER.debug("Initializing device with %s children", len(device.children)) + for idx, child in enumerate(device.children): + # HS300 does not like too many concurrent requests and its + # emeter data requires a request for each socket, so we receive + # separate coordinators. + if child_coordinators: + child_coordinator = child_coordinators[idx] + else: + child_coordinator = coordinator + entities.extend( + cls._entities_for_device( + child, + coordinator=child_coordinator, + feature_type=feature_type, + entity_class=entity_class, + descriptions=descriptions, + parent=device, + ) + ) + + return entities diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py new file mode 100644 index 00000000000..947a9072329 --- /dev/null +++ b/homeassistant/components/tplink/fan.py @@ -0,0 +1,111 @@ +"""Support for TPLink Fan devices.""" + +import logging +import math +from typing import Any + +from kasa import Device, Module +from kasa.interfaces import Fan as FanInterface + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range + +from . import TPLinkConfigEntry +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity, async_refresh_after + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up fans.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + entities: list[CoordinatedTPLinkEntity] = [] + if Module.Fan in device.modules: + entities.append( + TPLinkFanEntity( + device, parent_coordinator, fan_module=device.modules[Module.Fan] + ) + ) + entities.extend( + TPLinkFanEntity( + child, + parent_coordinator, + fan_module=child.modules[Module.Fan], + parent=device, + ) + for child in device.children + if Module.Fan in child.modules + ) + async_add_entities(entities) + + +SPEED_RANGE = (1, 4) # off is not included + + +class TPLinkFanEntity(CoordinatedTPLinkEntity, FanEntity): + """Representation of a fan for a TPLink Fan device.""" + + _attr_speed_count = int_states_in_range(SPEED_RANGE) + _attr_supported_features = FanEntityFeature.SET_SPEED + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + fan_module: FanInterface, + parent: Device | None = None, + ) -> None: + """Initialize the fan.""" + super().__init__(device, coordinator, parent=parent) + self.fan_module = fan_module + # If _attr_name is None the entity name will be the device name + self._attr_name = None if parent is None else device.alias + + @async_refresh_after + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is not None: + value_in_range = math.ceil( + percentage_to_ranged_value(SPEED_RANGE, percentage) + ) + else: + value_in_range = SPEED_RANGE[1] + await self.fan_module.set_fan_speed_level(value_in_range) + + @async_refresh_after + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self.fan_module.set_fan_speed_level(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + value_in_range = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + await self.fan_module.set_fan_speed_level(value_in_range) + + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + fan_speed = self.fan_module.fan_speed_level + self._attr_is_on = fan_speed != 0 + if self._attr_is_on: + self._attr_percentage = ranged_value_to_percentage(SPEED_RANGE, fan_speed) + else: + self._attr_percentage = None diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 9b83b3abc85..3da3b4806d3 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -1,11 +1,110 @@ { "entity": { + "binary_sensor": { + "humidity_warning": { + "default": "mdi:water-percent", + "state": { + "on": "mdi:water-percent-alert" + } + }, + "temperature_warning": { + "default": "mdi:thermometer-check", + "state": { + "on": "mdi:thermometer-alert" + } + } + }, + "button": { + "test_alarm": { + "default": "mdi:bell-alert" + }, + "stop_alarm": { + "default": "mdi:bell-cancel" + } + }, + "select": { + "light_preset": { + "default": "mdi:sign-direction" + }, + "alarm_sound": { + "default": "mdi:music-note" + }, + "alarm_volume": { + "default": "mdi:volume-medium", + "state": { + "low": "mdi:volume-low", + "medium": "mdi:volume-medium", + "high": "mdi:volume-high" + } + } + }, "switch": { "led": { "default": "mdi:led-off", "state": { "on": "mdi:led-on" } + }, + "auto_update_enabled": { + "default": "mdi:autorenew-off", + "state": { + "on": "mdi:autorenew" + } + }, + "auto_off_enabled": { + "default": "mdi:sleep-off", + "state": { + "on": "mdi:sleep" + } + }, + "smooth_transitions": { + "default": "mdi:transition-masked", + "state": { + "on": "mdi:transition" + } + }, + "fan_sleep_mode": { + "default": "mdi:sleep-off", + "state": { + "on": "mdi:sleep" + } + } + }, + "sensor": { + "on_since": { + "default": "mdi:clock" + }, + "ssid": { + "default": "mdi:wifi" + }, + "signal_level": { + "default": "mdi:signal" + }, + "current_firmware_version": { + "default": "mdi:information" + }, + "available_firmware_version": { + "default": "mdi:information-outline" + }, + "alarm_source": { + "default": "mdi:bell" + } + }, + "number": { + "smooth_transition_off": { + "default": "mdi:weather-sunset-down" + }, + "smooth_transition_on": { + "default": "mdi:weather-sunset-up" + }, + "auto_off_minutes": { + "default": "mdi:sleep" + }, + "temperature_offset": { + "default": "mdi:contrast" + }, + "target_temperature": { + "default": "mdi:thermometer" } } }, diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 977e75215aa..633648bbf23 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -4,9 +4,11 @@ from __future__ import annotations from collections.abc import Sequence import logging -from typing import Any, cast +from typing import Any -from kasa import SmartBulb, SmartLightStrip +from kasa import Device, DeviceType, LightState, Module +from kasa.interfaces import Light, LightEffect +from kasa.iot import IotDevice import voluptuous as vol from homeassistant.components.light import ( @@ -15,23 +17,21 @@ from homeassistant.components.light import ( ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, + EFFECT_OFF, ColorMode, LightEntity, LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import legacy_device_id -from .const import DOMAIN +from . import TPLinkConfigEntry, legacy_device_id from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after -from .models import TPLinkData _LOGGER = logging.getLogger(__name__) @@ -132,16 +132,24 @@ def _async_build_base_effect( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data parent_coordinator = data.parent_coordinator device = parent_coordinator.device - if device.is_light_strip: - async_add_entities( - [TPLinkSmartLightStrip(cast(SmartLightStrip, device), parent_coordinator)] + entities: list[TPLinkLightEntity | TPLinkLightEffectEntity] = [] + if ( + effect_module := device.modules.get(Module.LightEffect) + ) and effect_module.has_custom_effects: + entities.append( + TPLinkLightEffectEntity( + device, + parent_coordinator, + light_module=device.modules[Module.Light], + effect_module=effect_module, + ) ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -154,52 +162,83 @@ async def async_setup_entry( SEQUENCE_EFFECT_DICT, "async_set_sequence_effect", ) - elif device.is_bulb or device.is_dimmer: - async_add_entities( - [TPLinkSmartBulb(cast(SmartBulb, device), parent_coordinator)] + elif Module.Light in device.modules: + entities.append( + TPLinkLightEntity( + device, parent_coordinator, light_module=device.modules[Module.Light] + ) + ) + entities.extend( + TPLinkLightEntity( + child, + parent_coordinator, + light_module=child.modules[Module.Light], + parent=device, ) + for child in device.children + if Module.Light in child.modules + ) + async_add_entities(entities) -class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): +class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity): """Representation of a TPLink Smart Bulb.""" _attr_supported_features = LightEntityFeature.TRANSITION - _attr_name = None _fixed_color_mode: ColorMode | None = None - device: SmartBulb - def __init__( self, - device: SmartBulb, + device: Device, coordinator: TPLinkDataUpdateCoordinator, + *, + light_module: Light, + parent: Device | None = None, ) -> None: - """Initialize the switch.""" - super().__init__(device, coordinator) - # For backwards compat with pyHS100 - if device.is_dimmer: - # Dimmers used to use the switch format since - # pyHS100 treated them as SmartPlug but the old code - # created them as lights - # https://github.com/home-assistant/core/blob/2021.9.7/homeassistant/components/tplink/common.py#L86 - self._attr_unique_id = legacy_device_id(device) - else: - self._attr_unique_id = device.mac.replace(":", "").upper() + """Initialize the light.""" + self._parent = parent + super().__init__(device, coordinator, parent=parent) + self._light_module = light_module + # If _attr_name is None the entity name will be the device name + self._attr_name = None if parent is None else device.alias modes: set[ColorMode] = {ColorMode.ONOFF} - if device.is_variable_color_temp: + if light_module.is_variable_color_temp: modes.add(ColorMode.COLOR_TEMP) - temp_range = device.valid_temperature_range + temp_range = light_module.valid_temperature_range self._attr_min_color_temp_kelvin = temp_range.min self._attr_max_color_temp_kelvin = temp_range.max - if device.is_color: + if light_module.is_color: modes.add(ColorMode.HS) - if device.is_dimmable: + if light_module.is_dimmable: modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(modes) if len(self._attr_supported_color_modes) == 1: # If the light supports only a single color mode, set it now self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) - self._async_update_attrs() + self._async_call_update_attrs() + + def _get_unique_id(self) -> str: + """Return unique ID for the entity.""" + # For historical reasons the light platform uses the mac address as + # the unique id whereas all other platforms use device_id. + device = self._device + + # For backwards compat with pyHS100 + if device.device_type is DeviceType.Dimmer and isinstance(device, IotDevice): + # Dimmers used to use the switch format since + # pyHS100 treated them as SmartPlug but the old code + # created them as lights + # https://github.com/home-assistant/core/blob/2021.9.7/ \ + # homeassistant/components/tplink/common.py#L86 + return legacy_device_id(device) + + # Newer devices can have child lights. While there isn't currently + # an example of a device with more than one light we use the device_id + # for consistency and future proofing + if self._parent or device.children: + return legacy_device_id(device) + + return device.mac.replace(":", "").upper() @callback def _async_extract_brightness_transition( @@ -211,12 +250,12 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None: brightness = round((brightness * 100.0) / 255.0) - if self.device.is_dimmer and transition is None: - # This is a stopgap solution for inconsistent set_brightness handling - # in the upstream library, see #57265. + if self._device.device_type is DeviceType.Dimmer and transition is None: + # This is a stopgap solution for inconsistent set_brightness + # handling in the upstream library, see #57265. # This should be removed when the upstream has fixed the issue. # The device logic is to change the settings without turning it on - # except when transition is defined, so we leverage that here for now. + # except when transition is defined so we leverage that for now. transition = 1 return brightness, transition @@ -226,13 +265,13 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): ) -> None: # TP-Link requires integers. hue, sat = tuple(int(val) for val in hs_color) - await self.device.set_hsv(hue, sat, brightness, transition=transition) + await self._light_module.set_hsv(hue, sat, brightness, transition=transition) async def _async_set_color_temp( self, color_temp: float, brightness: int | None, transition: int | None ) -> None: - device = self.device - valid_temperature_range = device.valid_temperature_range + light_module = self._light_module + valid_temperature_range = light_module.valid_temperature_range requested_color_temp = round(color_temp) # Clamp color temp to valid range # since if the light in a group we will @@ -242,7 +281,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): valid_temperature_range.max, max(valid_temperature_range.min, requested_color_temp), ) - await device.set_color_temp( + await light_module.set_color_temp( clamped_color_temp, brightness=brightness, transition=transition, @@ -253,9 +292,11 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): ) -> None: # Fallback to adjusting brightness or turning the bulb on if brightness is not None: - await self.device.set_brightness(brightness, transition=transition) + await self._light_module.set_brightness(brightness, transition=transition) return - await self.device.turn_on(transition=transition) + await self._light_module.set_state( + LightState(light_on=True, transition=transition) + ) @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -275,7 +316,9 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Turn the light off.""" if (transition := kwargs.get(ATTR_TRANSITION)) is not None: transition = int(transition * 1_000) - await self.device.turn_off(transition=transition) + await self._light_module.set_state( + LightState(light_on=False, transition=transition) + ) def _determine_color_mode(self) -> ColorMode: """Return the active color mode.""" @@ -284,48 +327,53 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return self._fixed_color_mode # The light supports both color temp and color, determine which on is active - if self.device.is_variable_color_temp and self.device.color_temp: + if self._light_module.is_variable_color_temp and self._light_module.color_temp: return ColorMode.COLOR_TEMP return ColorMode.HS @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" - device = self.device - self._attr_is_on = device.is_on - if device.is_dimmable: - self._attr_brightness = round((device.brightness * 255.0) / 100.0) + light_module = self._light_module + self._attr_is_on = light_module.state.light_on is True + if light_module.is_dimmable: + self._attr_brightness = round((light_module.brightness * 255.0) / 100.0) color_mode = self._determine_color_mode() self._attr_color_mode = color_mode if color_mode is ColorMode.COLOR_TEMP: - self._attr_color_temp_kelvin = device.color_temp + self._attr_color_temp_kelvin = light_module.color_temp elif color_mode is ColorMode.HS: - hue, saturation, _ = device.hsv + hue, saturation, _ = light_module.hsv self._attr_hs_color = hue, saturation - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._async_update_attrs() - super()._handle_coordinator_update() - -class TPLinkSmartLightStrip(TPLinkSmartBulb): +class TPLinkLightEffectEntity(TPLinkLightEntity): """Representation of a TPLink Smart Light Strip.""" - device: SmartLightStrip + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + light_module: Light, + effect_module: LightEffect, + ) -> None: + """Initialize the light strip.""" + self._effect_module = effect_module + super().__init__(device, coordinator, light_module=light_module) + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" super()._async_update_attrs() - device = self.device - if (effect := device.effect) and effect["enable"]: - self._attr_effect = effect["name"] + effect_module = self._effect_module + if effect_module.effect != LightEffect.LIGHT_EFFECTS_OFF: + self._attr_effect = effect_module.effect else: - self._attr_effect = None - if effect_list := device.effect_list: + self._attr_effect = EFFECT_OFF + if effect_list := effect_module.effect_list: self._attr_effect_list = effect_list else: self._attr_effect_list = None @@ -335,15 +383,15 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): """Turn the light on.""" brightness, transition = self._async_extract_brightness_transition(**kwargs) if ATTR_EFFECT in kwargs: - await self.device.set_effect( + await self._effect_module.set_effect( kwargs[ATTR_EFFECT], brightness=brightness, transition=transition ) elif ATTR_COLOR_TEMP_KELVIN in kwargs: if self.effect: # If there is an effect in progress - # we have to set an HSV value to clear the effect + # we have to clear the effect # before we can set a color temp - await self.device.set_hsv(0, 0, brightness) + await self._light_module.set_hsv(0, 0, brightness) await self._async_set_color_temp( kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition ) @@ -390,7 +438,7 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): if transition_range: effect["transition_range"] = transition_range effect["transition"] = 0 - await self.device.set_custom_effect(effect) + await self._effect_module.set_custom_effect(effect) async def async_set_sequence_effect( self, @@ -412,4 +460,4 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): "spread": spread, "direction": direction, } - await self.device.set_custom_effect(effect) + await self._effect_module.set_custom_effect(effect) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a91e7e5a46f..5b8e6f8fc1b 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -40,6 +40,10 @@ "hostname": "k[lps]*", "macaddress": "5091E3*" }, + { + "hostname": "p1*", + "macaddress": "5091E3*" + }, { "hostname": "k[lps]*", "macaddress": "9C5322*" @@ -216,14 +220,26 @@ "hostname": "s5*", "macaddress": "3C52A1*" }, + { + "hostname": "h1*", + "macaddress": "3C52A1*" + }, { "hostname": "l9*", "macaddress": "A842A1*" }, + { + "hostname": "p1*", + "macaddress": "A842A1*" + }, { "hostname": "l9*", "macaddress": "3460F9*" }, + { + "hostname": "p1*", + "macaddress": "3460F9*" + }, { "hostname": "hs*", "macaddress": "704F57*" @@ -232,6 +248,10 @@ "hostname": "k[lps]*", "macaddress": "74DA88*" }, + { + "hostname": "p1*", + "macaddress": "74DA88*" + }, { "hostname": "p3*", "macaddress": "788CB5*" @@ -263,11 +283,19 @@ { "hostname": "l9*", "macaddress": "F0A731*" + }, + { + "hostname": "ks2*", + "macaddress": "F0A731*" + }, + { + "hostname": "kh1*", + "macaddress": "F0A731*" } ], "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.6.2.1"] + "requirements": ["python-kasa[speedups]==0.7.0.1"] } diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py new file mode 100644 index 00000000000..4b273800e6a --- /dev/null +++ b/homeassistant/components/tplink/number.py @@ -0,0 +1,108 @@ +"""Support for TPLink number entities.""" + +from __future__ import annotations + +import logging +from typing import Final + +from kasa import Device, Feature + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .entity import ( + CoordinatedTPLinkFeatureEntity, + TPLinkDataUpdateCoordinator, + TPLinkFeatureEntityDescription, + async_refresh_after, +) + +_LOGGER = logging.getLogger(__name__) + + +class TPLinkNumberEntityDescription( + NumberEntityDescription, TPLinkFeatureEntityDescription +): + """Base class for a TPLink feature based sensor entity description.""" + + +NUMBER_DESCRIPTIONS: Final = ( + TPLinkNumberEntityDescription( + key="smooth_transition_on", + mode=NumberMode.BOX, + ), + TPLinkNumberEntityDescription( + key="smooth_transition_off", + mode=NumberMode.BOX, + ), + TPLinkNumberEntityDescription( + key="auto_off_minutes", + mode=NumberMode.BOX, + ), + TPLinkNumberEntityDescription( + key="temperature_offset", + mode=NumberMode.BOX, + ), +) + +NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + children_coordinators = data.children_coordinators + device = parent_coordinator.device + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Number, + entity_class=TPLinkNumberEntity, + descriptions=NUMBER_DESCRIPTIONS_MAP, + child_coordinators=children_coordinators, + ) + + async_add_entities(entities) + + +class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity): + """Representation of a feature-based TPLink sensor.""" + + entity_description: TPLinkNumberEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + feature: Feature, + description: TPLinkFeatureEntityDescription, + parent: Device | None = None, + ) -> None: + """Initialize the a switch.""" + super().__init__( + device, coordinator, feature=feature, description=description, parent=parent + ) + self._attr_native_min_value = self._feature.minimum_value + self._attr_native_max_value = self._feature.maximum_value + + @async_refresh_after + async def async_set_native_value(self, value: float) -> None: + """Set feature value.""" + await self._feature.set_value(int(value)) + + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_native_value = self._feature.value diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py new file mode 100644 index 00000000000..41703b27e5a --- /dev/null +++ b/homeassistant/components/tplink/select.py @@ -0,0 +1,95 @@ +"""Support for TPLink select entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, cast + +from kasa import Device, Feature + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .entity import ( + CoordinatedTPLinkFeatureEntity, + TPLinkDataUpdateCoordinator, + TPLinkFeatureEntityDescription, + async_refresh_after, +) + + +@dataclass(frozen=True, kw_only=True) +class TPLinkSelectEntityDescription( + SelectEntityDescription, TPLinkFeatureEntityDescription +): + """Base class for a TPLink feature based sensor entity description.""" + + +SELECT_DESCRIPTIONS: Final = [ + TPLinkSelectEntityDescription( + key="light_preset", + ), + TPLinkSelectEntityDescription( + key="alarm_sound", + ), + TPLinkSelectEntityDescription( + key="alarm_volume", + ), +] + +SELECT_DESCRIPTIONS_MAP = {desc.key: desc for desc in SELECT_DESCRIPTIONS} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + children_coordinators = data.children_coordinators + device = parent_coordinator.device + + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Choice, + entity_class=TPLinkSelectEntity, + descriptions=SELECT_DESCRIPTIONS_MAP, + child_coordinators=children_coordinators, + ) + async_add_entities(entities) + + +class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity): + """Representation of a tplink select entity.""" + + entity_description: TPLinkSelectEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + *, + feature: Feature, + description: TPLinkFeatureEntityDescription, + parent: Device | None = None, + ) -> None: + """Initialize a select.""" + super().__init__( + device, coordinator, feature=feature, description=description, parent=parent + ) + self._attr_options = cast(list, self._feature.choices) + + @async_refresh_after + async def async_select_option(self, option: str) -> None: + """Update the current selected option.""" + await self._feature.set_value(option) + + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_current_option = self._feature.value diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index d7563dd0401..474ee6bfacf 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -1,11 +1,11 @@ -"""Support for TPLink HS100/HS110/HS200 smart switch energy sensors.""" +"""Support for TPLink sensor entities.""" from __future__ import annotations from dataclasses import dataclass from typing import cast -from kasa import SmartDevice +from kasa import Device, Feature from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,175 +13,164 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_VOLTAGE, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, -) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import legacy_device_id -from .const import ( - ATTR_CURRENT_A, - ATTR_CURRENT_POWER_W, - ATTR_TODAY_ENERGY_KWH, - ATTR_TOTAL_ENERGY_KWH, - DOMAIN, -) +from . import TPLinkConfigEntry +from .const import UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity -from .models import TPLinkData - +from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription -@dataclass(frozen=True) -class TPLinkSensorEntityDescription(SensorEntityDescription): - """Describes TPLink sensor entity.""" - emeter_attr: str | None = None - precision: int | None = None +@dataclass(frozen=True, kw_only=True) +class TPLinkSensorEntityDescription( + SensorEntityDescription, TPLinkFeatureEntityDescription +): + """Base class for a TPLink feature based sensor entity description.""" -ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( +SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( - key=ATTR_CURRENT_POWER_W, - translation_key="current_consumption", - native_unit_of_measurement=UnitOfPower.WATT, + key="current_consumption", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, - emeter_attr="power", - precision=1, ), TPLinkSensorEntityDescription( - key=ATTR_TOTAL_ENERGY_KWH, - translation_key="total_consumption", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + key="consumption_total", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - emeter_attr="total", - precision=3, ), TPLinkSensorEntityDescription( - key=ATTR_TODAY_ENERGY_KWH, - translation_key="today_consumption", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + key="consumption_today", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=3, ), TPLinkSensorEntityDescription( - key=ATTR_VOLTAGE, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, + key="consumption_this_month", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TPLinkSensorEntityDescription( + key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - emeter_attr="voltage", - precision=1, ), TPLinkSensorEntityDescription( - key=ATTR_CURRENT_A, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - emeter_attr="current", - precision=2, + ), + TPLinkSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TPLinkSensorEntityDescription( + # Disable as the value reported by the device changes seconds frequently + entity_registry_enabled_default=False, + key="on_since", + device_class=SensorDeviceClass.TIMESTAMP, + ), + TPLinkSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), + TPLinkSensorEntityDescription( + key="signal_level", + state_class=SensorStateClass.MEASUREMENT, + ), + TPLinkSensorEntityDescription( + key="ssid", + ), + TPLinkSensorEntityDescription( + key="battery_level", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + TPLinkSensorEntityDescription( + key="auto_off_at", + device_class=SensorDeviceClass.TIMESTAMP, + ), + TPLinkSensorEntityDescription( + key="device_time", + device_class=SensorDeviceClass.TIMESTAMP, + ), + TPLinkSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TPLinkSensorEntityDescription( + key="report_interval", + device_class=SensorDeviceClass.DURATION, + ), + TPLinkSensorEntityDescription( + key="alarm_source", + ), + TPLinkSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, ), ) - -def async_emeter_from_device( - device: SmartDevice, description: TPLinkSensorEntityDescription -) -> float | None: - """Map a sensor key to the device attribute.""" - if attr := description.emeter_attr: - if (val := getattr(device.emeter_realtime, attr)) is None: - return None - return round(cast(float, val), description.precision) - - # ATTR_TODAY_ENERGY_KWH - if (emeter_today := device.emeter_today) is not None: - return round(cast(float, emeter_today), description.precision) - # today's consumption not available, when device was off all the day - # bulb's do not report this information, so filter it out - return None if device.is_bulb else 0.0 - - -def _async_sensors_for_device( - device: SmartDevice, - coordinator: TPLinkDataUpdateCoordinator, - has_parent: bool = False, -) -> list[SmartPlugSensor]: - """Generate the sensors for the device.""" - return [ - SmartPlugSensor(device, coordinator, description, has_parent) - for description in ENERGY_SENSORS - if async_emeter_from_device(device, description) is not None - ] +SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors.""" - data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data parent_coordinator = data.parent_coordinator children_coordinators = data.children_coordinators - entities: list[SmartPlugSensor] = [] - parent = parent_coordinator.device - if not parent.has_emeter: - return - - if parent.is_strip: - # Historically we only add the children if the device is a strip - for idx, child in enumerate(parent.children): - entities.extend( - _async_sensors_for_device(child, children_coordinators[idx], True) - ) - else: - entities.extend(_async_sensors_for_device(parent, parent_coordinator)) - + device = parent_coordinator.device + + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + device=device, + coordinator=parent_coordinator, + feature_type=Feature.Type.Sensor, + entity_class=TPLinkSensorEntity, + descriptions=SENSOR_DESCRIPTIONS_MAP, + child_coordinators=children_coordinators, + ) async_add_entities(entities) -class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): - """Representation of a TPLink Smart Plug energy sensor.""" +class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): + """Representation of a feature-based TPLink sensor.""" entity_description: TPLinkSensorEntityDescription def __init__( self, - device: SmartDevice, + device: Device, coordinator: TPLinkDataUpdateCoordinator, + *, + feature: Feature, description: TPLinkSensorEntityDescription, - has_parent: bool = False, + parent: Device | None = None, ) -> None: - """Initialize the switch.""" - super().__init__(device, coordinator) - self.entity_description = description - self._attr_unique_id = f"{legacy_device_id(device)}_{description.key}" - if has_parent: - assert device.alias - self._attr_translation_placeholders = {"device_name": device.alias} - if description.translation_key: - self._attr_translation_key = f"{description.translation_key}_child" - else: - assert description.device_class - self._attr_translation_key = f"{description.device_class.value}_child" - self._async_update_attrs() + """Initialize the sensor.""" + super().__init__( + device, coordinator, description=description, feature=feature, parent=parent + ) + self._async_call_update_attrs() @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" - self._attr_native_value = async_emeter_from_device( - self.device, self.entity_description - ) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._async_update_attrs() - super()._handle_coordinator_update() + value = self._feature.value + if value is not None and self._feature.precision_hint is not None: + value = round(cast(float, value), self._feature.precision_hint) + # We probably do not need this, when we are rounding already? + self._attr_suggested_display_precision = self._feature.precision_hint + + self._attr_native_value = value + # Map to homeassistant units and fallback to upstream one if none found + if self._feature.unit is not None: + self._attr_native_unit_of_measurement = UNIT_MAPPING.get( + self._feature.unit, self._feature.unit + ) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index c863df7c81c..34ce96612f5 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -59,35 +59,151 @@ } }, "entity": { + "binary_sensor": { + "humidity_warning": { + "name": "Humidity warning" + }, + "temperature_warning": { + "name": "Temperature warning" + }, + "overheated": { + "name": "Overheated" + }, + "battery_low": { + "name": "Battery low" + }, + "cloud_connection": { + "name": "Cloud connection" + }, + "update_available": { + "name": "[%key:component::binary_sensor::entity_component::update::name%]", + "state": { + "off": "[%key:component::binary_sensor::entity_component::update::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::update::state::on%]" + } + }, + "is_open": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]", + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } + }, + "water_alert": { + "name": "[%key:component::binary_sensor::entity_component::moisture::name%]", + "state": { + "off": "[%key:component::binary_sensor::entity_component::moisture::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::moisture::state::on%]" + } + } + }, + "button": { + "test_alarm": { + "name": "Test alarm" + }, + "stop_alarm": { + "name": "Stop alarm" + } + }, + "select": { + "light_preset": { + "name": "Light preset" + }, + "alarm_sound": { + "name": "Alarm sound" + }, + "alarm_volume": { + "name": "Alarm volume" + } + }, "sensor": { "current_consumption": { "name": "Current consumption" }, - "total_consumption": { + "consumption_total": { "name": "Total consumption" }, - "today_consumption": { + "consumption_today": { "name": "Today's consumption" }, - "current_consumption_child": { - "name": "{device_name} current consumption" + "consumption_this_month": { + "name": "This month's consumption" + }, + "on_since": { + "name": "On since" }, - "total_consumption_child": { - "name": "{device_name} total consumption" + "ssid": { + "name": "SSID" }, - "today_consumption_child": { - "name": "{device_name} today's consumption" + "signal_level": { + "name": "Signal level" }, - "current_child": { - "name": "{device_name} current" + "current_firmware_version": { + "name": "Current firmware version" }, - "voltage_child": { - "name": "{device_name} voltage" + "available_firmware_version": { + "name": "Available firmware version" + }, + "battery_level": { + "name": "Battery level" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "current": { + "name": "[%key:component::sensor::entity_component::current::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "device_time": { + "name": "Device time" + }, + "auto_off_at": { + "name": "Auto off at" + }, + "report_interval": { + "name": "Report interval" + }, + "alarm_source": { + "name": "Alarm source" + }, + "rssi": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" } }, "switch": { "led": { "name": "LED" + }, + "auto_update_enabled": { + "name": "Auto update enabled" + }, + "auto_off_enabled": { + "name": "Auto off enabled" + }, + "smooth_transitions": { + "name": "Smooth transitions" + }, + "fan_sleep_mode": { + "name": "Fan sleep mode" + } + }, + "number": { + "smooth_transition_on": { + "name": "Smooth on" + }, + "smooth_transition_off": { + "name": "Smooth off" + }, + "auto_off_minutes": { + "name": "Turn off in" + }, + "temperature_offset": { + "name": "Temperature offset" } } }, diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index da3dda9c041..2520de9dd3e 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -1,158 +1,112 @@ -"""Support for TPLink HS100/HS110/HS200 smart switch.""" +"""Support for TPLink switch entities.""" from __future__ import annotations +from dataclasses import dataclass import logging -from typing import Any, cast +from typing import Any -from kasa import SmartDevice, SmartPlug +from kasa import Device, Feature -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import legacy_device_id -from .const import DOMAIN +from . import TPLinkConfigEntry from .coordinator import TPLinkDataUpdateCoordinator -from .entity import CoordinatedTPLinkEntity, async_refresh_after -from .models import TPLinkData +from .entity import ( + CoordinatedTPLinkFeatureEntity, + TPLinkFeatureEntityDescription, + async_refresh_after, +) _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class TPLinkSwitchEntityDescription( + SwitchEntityDescription, TPLinkFeatureEntityDescription +): + """Base class for a TPLink feature based sensor entity description.""" + + +SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( + TPLinkSwitchEntityDescription( + key="state", + ), + TPLinkSwitchEntityDescription( + key="led", + ), + TPLinkSwitchEntityDescription( + key="auto_update_enabled", + ), + TPLinkSwitchEntityDescription( + key="auto_off_enabled", + ), + TPLinkSwitchEntityDescription( + key="smooth_transitions", + ), + TPLinkSwitchEntityDescription( + key="fan_sleep_mode", + ), +) + +SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TPLinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data parent_coordinator = data.parent_coordinator - device = cast(SmartPlug, parent_coordinator.device) - if not device.is_plug and not device.is_strip and not device.is_dimmer: - return - entities: list = [] - if device.is_strip: - # Historically we only add the children if the device is a strip - _LOGGER.debug("Initializing strip with %s sockets", len(device.children)) - entities.extend( - SmartPlugSwitchChild(device, parent_coordinator, child) - for child in device.children - ) - elif device.is_plug: - entities.append(SmartPlugSwitch(device, parent_coordinator)) + device = parent_coordinator.device - # this will be removed on the led is implemented - if hasattr(device, "led"): - entities.append(SmartPlugLedSwitch(device, parent_coordinator)) + entities = CoordinatedTPLinkFeatureEntity.entities_for_device_and_its_children( + device, + coordinator=parent_coordinator, + feature_type=Feature.Switch, + entity_class=TPLinkSwitch, + descriptions=SWITCH_DESCRIPTIONS_MAP, + ) async_add_entities(entities) -class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): - """Representation of switch for the LED of a TPLink Smart Plug.""" - - device: SmartPlug - - _attr_translation_key = "led" - _attr_entity_category = EntityCategory.CONFIG - - def __init__( - self, device: SmartPlug, coordinator: TPLinkDataUpdateCoordinator - ) -> None: - """Initialize the LED switch.""" - super().__init__(device, coordinator) - self._attr_unique_id = f"{device.mac}_led" - self._async_update_attrs() - - @async_refresh_after - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the LED switch on.""" - await self.device.set_led(True) - - @async_refresh_after - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the LED switch off.""" - await self.device.set_led(False) - - @callback - def _async_update_attrs(self) -> None: - """Update the entity's attributes.""" - self._attr_is_on = self.device.led - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._async_update_attrs() - super()._handle_coordinator_update() - - -class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): - """Representation of a TPLink Smart Plug switch.""" +class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity): + """Representation of a feature-based TPLink switch.""" - _attr_name: str | None = None + entity_description: TPLinkSwitchEntityDescription def __init__( self, - device: SmartDevice, + device: Device, coordinator: TPLinkDataUpdateCoordinator, + *, + feature: Feature, + description: TPLinkSwitchEntityDescription, + parent: Device | None = None, ) -> None: """Initialize the switch.""" - super().__init__(device, coordinator) - # For backwards compat with pyHS100 - self._attr_unique_id = legacy_device_id(device) - self._async_update_attrs() + super().__init__( + device, coordinator, description=description, feature=feature, parent=parent + ) + + self._async_call_update_attrs() @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.device.turn_on() + await self._feature.set_value(True) @async_refresh_after async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.device.turn_off() - - @callback - def _async_update_attrs(self) -> None: - """Update the entity's attributes.""" - self._attr_is_on = self.device.is_on - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._async_update_attrs() - super()._handle_coordinator_update() - - -class SmartPlugSwitchChild(SmartPlugSwitch): - """Representation of an individual plug of a TPLink Smart Plug strip.""" - - def __init__( - self, - device: SmartDevice, - coordinator: TPLinkDataUpdateCoordinator, - plug: SmartDevice, - ) -> None: - """Initialize the child switch.""" - self._plug = plug - super().__init__(device, coordinator) - self._attr_unique_id = legacy_device_id(plug) - self._attr_name = plug.alias - - @async_refresh_after - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the child switch on.""" - await self._plug.turn_on() - - @async_refresh_after - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the child switch off.""" - await self._plug.turn_off() + await self._feature.set_value(False) @callback def _async_update_attrs(self) -> None: """Update the entity's attributes.""" - self._attr_is_on = self._plug.is_on + self._attr_is_on = self._feature.value diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 3b5fe9843f2..e898f64d128 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -650,6 +650,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "k[lps]*", "macaddress": "5091E3*", }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "5091E3*", + }, { "domain": "tplink", "hostname": "k[lps]*", @@ -870,16 +875,31 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "s5*", "macaddress": "3C52A1*", }, + { + "domain": "tplink", + "hostname": "h1*", + "macaddress": "3C52A1*", + }, { "domain": "tplink", "hostname": "l9*", "macaddress": "A842A1*", }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "A842A1*", + }, { "domain": "tplink", "hostname": "l9*", "macaddress": "3460F9*", }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "3460F9*", + }, { "domain": "tplink", "hostname": "hs*", @@ -890,6 +910,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "k[lps]*", "macaddress": "74DA88*", }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "74DA88*", + }, { "domain": "tplink", "hostname": "p3*", @@ -930,6 +955,16 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "l9*", "macaddress": "F0A731*", }, + { + "domain": "tplink", + "hostname": "ks2*", + "macaddress": "F0A731*", + }, + { + "domain": "tplink", + "hostname": "kh1*", + "macaddress": "F0A731*", + }, { "domain": "tuya", "macaddress": "105A17*", diff --git a/requirements_all.txt b/requirements_all.txt index b75555283a2..de167c2f7e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2278,7 +2278,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.6.2.1 +python-kasa[speedups]==0.7.0.1 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 37b3700372b..8eb468b0947 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1778,7 +1778,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.6.2.1 +python-kasa[speedups]==0.7.0.1 # homeassistant.components.matter python-matter-server==6.1.0 diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index d1454d12e68..9c8aeb99be1 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -1,21 +1,24 @@ """Tests for the TP-Link component.""" +from collections import namedtuple +from datetime import datetime +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from kasa import ( - ConnectionType, + Device, DeviceConfig, - DeviceFamilyType, - EncryptType, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, + DeviceType, + Feature, + KasaException, + Module, ) -from kasa.exceptions import SmartDeviceException +from kasa.interfaces import Fan, Light, LightEffect, LightState from kasa.protocol import BaseProtocol +from syrupy import SnapshotAssertion from homeassistant.components.tplink import ( CONF_ALIAS, @@ -25,9 +28,17 @@ from homeassistant.components.tplink import ( Credentials, ) from homeassistant.components.tplink.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.translation import async_get_translations +from homeassistant.helpers.typing import UNDEFINED +from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_value_fixture + +ColorTempRange = namedtuple("ColorTempRange", ["min", "max"]) MODULE = "homeassistant.components.tplink" MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" @@ -36,6 +47,7 @@ IP_ADDRESS2 = "127.0.0.2" ALIAS = "My Bulb" MODEL = "HS100" MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DEVICE_ID = "123456789ABCDEFGH" DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") MAC_ADDRESS2 = "11:22:33:44:55:66" DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" @@ -49,16 +61,16 @@ CREDENTIALS_HASH_AUTH = "abcdefghijklmnopqrstuv==" DEVICE_CONFIG_AUTH = DeviceConfig( IP_ADDRESS, credentials=CREDENTIALS, - connection_type=ConnectionType( - DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Klap + connection_type=DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Klap ), uses_http=True, ) DEVICE_CONFIG_AUTH2 = DeviceConfig( IP_ADDRESS2, credentials=CREDENTIALS, - connection_type=ConnectionType( - DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Klap + connection_type=DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Klap ), uses_http=True, ) @@ -90,190 +102,316 @@ CREATE_ENTRY_DATA_AUTH2 = { } +def _load_feature_fixtures(): + fixtures = load_json_value_fixture("features.json", DOMAIN) + for fixture in fixtures.values(): + if isinstance(fixture["value"], str): + try: + time = datetime.strptime(fixture["value"], "%Y-%m-%d %H:%M:%S.%f%z") + fixture["value"] = time + except ValueError: + pass + return fixtures + + +FEATURES_FIXTURE = _load_feature_fixtures() + + +async def setup_platform_for_device( + hass: HomeAssistant, config_entry: ConfigEntry, platform: Platform, device: Device +): + """Set up a single tplink platform with a device.""" + config_entry.add_to_hass(hass) + + with ( + patch("homeassistant.components.tplink.PLATFORMS", [platform]), + _patch_discovery(device=device), + _patch_connect(device=device), + ): + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + # Good practice to wait background tasks in tests see PR #112726 + await hass.async_block_till_done(wait_background_tasks=True) + + +async def snapshot_platform( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + config_entry_id: str, +) -> None: + """Snapshot a platform.""" + device_entries = dr.async_entries_for_config_entry(device_registry, config_entry_id) + assert device_entries + for device_entry in device_entries: + assert device_entry == snapshot( + name=f"{device_entry.name}-entry" + ), f"device entry snapshot failed for {device_entry.name}" + + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert entity_entries + assert ( + len({entity_entry.domain for entity_entry in entity_entries}) == 1 + ), "Please limit the loaded platforms to 1 platform." + + translations = await async_get_translations(hass, "en", "entity", [DOMAIN]) + for entity_entry in entity_entries: + if entity_entry.translation_key: + key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" + assert ( + key in translations + ), f"No translation for entity {entity_entry.unique_id}, expected {key}" + assert entity_entry == snapshot( + name=f"{entity_entry.entity_id}-entry" + ), f"entity entry snapshot failed for {entity_entry.entity_id}" + if entity_entry.disabled_by is None: + state = hass.states.get(entity_entry.entity_id) + assert state, f"State not found for {entity_entry.entity_id}" + assert state == snapshot( + name=f"{entity_entry.entity_id}-state" + ), f"state snapshot failed for {entity_entry.entity_id}" + + def _mock_protocol() -> BaseProtocol: - protocol = MagicMock(auto_spec=BaseProtocol) + protocol = MagicMock(spec=BaseProtocol) protocol.close = AsyncMock() return protocol -def _mocked_bulb( +def _mocked_device( device_config=DEVICE_CONFIG_LEGACY, credentials_hash=CREDENTIALS_HASH_LEGACY, mac=MAC_ADDRESS, + device_id=DEVICE_ID, alias=ALIAS, -) -> SmartBulb: - bulb = MagicMock(auto_spec=SmartBulb, name="Mocked bulb") - bulb.update = AsyncMock() - bulb.mac = mac - bulb.alias = alias - bulb.model = MODEL - bulb.host = IP_ADDRESS - bulb.brightness = 50 - bulb.color_temp = 4000 - bulb.is_color = True - bulb.is_strip = False - bulb.is_plug = False - bulb.is_dimmer = False - bulb.is_light_strip = False - bulb.has_effects = False - bulb.effect = None - bulb.effect_list = None - bulb.hsv = (10, 30, 5) - bulb.device_id = mac - bulb.valid_temperature_range.min = 4000 - bulb.valid_temperature_range.max = 9000 - bulb.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} - bulb.turn_off = AsyncMock() - bulb.turn_on = AsyncMock() - bulb.set_brightness = AsyncMock() - bulb.set_hsv = AsyncMock() - bulb.set_color_temp = AsyncMock() - bulb.protocol = _mock_protocol() - bulb.config = device_config - bulb.credentials_hash = credentials_hash - return bulb - - -class MockedSmartLightStrip(SmartLightStrip): - """Mock a SmartLightStrip.""" - - def __new__(cls, *args, **kwargs): - """Mock a SmartLightStrip that will pass an isinstance check.""" - return MagicMock(spec=cls) - - -def _mocked_smart_light_strip() -> SmartLightStrip: - strip = MockedSmartLightStrip() - strip.update = AsyncMock() - strip.mac = MAC_ADDRESS - strip.alias = ALIAS - strip.model = MODEL - strip.host = IP_ADDRESS - strip.brightness = 50 - strip.color_temp = 4000 - strip.is_color = True - strip.is_strip = False - strip.is_plug = False - strip.is_dimmer = False - strip.is_light_strip = True - strip.has_effects = True - strip.effect = {"name": "Effect1", "enable": 1} - strip.effect_list = ["Effect1", "Effect2"] - strip.hsv = (10, 30, 5) - strip.device_id = MAC_ADDRESS - strip.valid_temperature_range.min = 4000 - strip.valid_temperature_range.max = 9000 - strip.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} - strip.turn_off = AsyncMock() - strip.turn_on = AsyncMock() - strip.set_brightness = AsyncMock() - strip.set_hsv = AsyncMock() - strip.set_color_temp = AsyncMock() - strip.set_effect = AsyncMock() - strip.set_custom_effect = AsyncMock() - strip.protocol = _mock_protocol() - strip.config = DEVICE_CONFIG_LEGACY - strip.credentials_hash = CREDENTIALS_HASH_LEGACY - return strip - - -def _mocked_dimmer() -> SmartDimmer: - dimmer = MagicMock(auto_spec=SmartDimmer, name="Mocked dimmer") - dimmer.update = AsyncMock() - dimmer.mac = MAC_ADDRESS - dimmer.alias = "My Dimmer" - dimmer.model = MODEL - dimmer.host = IP_ADDRESS - dimmer.brightness = 50 - dimmer.color_temp = 4000 - dimmer.is_color = True - dimmer.is_strip = False - dimmer.is_plug = False - dimmer.is_dimmer = True - dimmer.is_light_strip = False - dimmer.effect = None - dimmer.effect_list = None - dimmer.hsv = (10, 30, 5) - dimmer.device_id = MAC_ADDRESS - dimmer.valid_temperature_range.min = 4000 - dimmer.valid_temperature_range.max = 9000 - dimmer.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} - dimmer.turn_off = AsyncMock() - dimmer.turn_on = AsyncMock() - dimmer.set_brightness = AsyncMock() - dimmer.set_hsv = AsyncMock() - dimmer.set_color_temp = AsyncMock() - dimmer.set_led = AsyncMock() - dimmer.protocol = _mock_protocol() - dimmer.config = DEVICE_CONFIG_LEGACY - dimmer.credentials_hash = CREDENTIALS_HASH_LEGACY - return dimmer - - -def _mocked_plug() -> SmartPlug: - plug = MagicMock(auto_spec=SmartPlug, name="Mocked plug") - plug.update = AsyncMock() - plug.mac = MAC_ADDRESS - plug.alias = "My Plug" - plug.model = MODEL - plug.host = IP_ADDRESS - plug.is_light_strip = False - plug.is_bulb = False - plug.is_dimmer = False - plug.is_strip = False - plug.is_plug = True - plug.device_id = MAC_ADDRESS - plug.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} - plug.turn_off = AsyncMock() - plug.turn_on = AsyncMock() - plug.set_led = AsyncMock() - plug.protocol = _mock_protocol() - plug.config = DEVICE_CONFIG_LEGACY - plug.credentials_hash = CREDENTIALS_HASH_LEGACY - return plug - - -def _mocked_strip() -> SmartStrip: - strip = MagicMock(auto_spec=SmartStrip, name="Mocked strip") - strip.update = AsyncMock() - strip.mac = MAC_ADDRESS - strip.alias = "My Strip" - strip.model = MODEL - strip.host = IP_ADDRESS - strip.is_light_strip = False - strip.is_bulb = False - strip.is_dimmer = False - strip.is_strip = True - strip.is_plug = True - strip.device_id = MAC_ADDRESS - strip.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} - strip.turn_off = AsyncMock() - strip.turn_on = AsyncMock() - strip.set_led = AsyncMock() - strip.protocol = _mock_protocol() - strip.config = DEVICE_CONFIG_LEGACY - strip.credentials_hash = CREDENTIALS_HASH_LEGACY - plug0 = _mocked_plug() - plug0.alias = "Plug0" - plug0.device_id = "bb:bb:cc:dd:ee:ff_PLUG0DEVICEID" - plug0.mac = "bb:bb:cc:dd:ee:ff" + model=MODEL, + ip_address=IP_ADDRESS, + modules: list[str] | None = None, + children: list[Device] | None = None, + features: list[str | Feature] | None = None, + device_type=None, + spec: type = Device, +) -> Device: + device = MagicMock(spec=spec, name="Mocked device") + device.update = AsyncMock() + device.turn_off = AsyncMock() + device.turn_on = AsyncMock() + + device.mac = mac + device.alias = alias + device.model = model + device.host = ip_address + device.device_id = device_id + device.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} + device.modules = {} + device.features = {} + + if modules: + device.modules = { + module_name: MODULE_TO_MOCK_GEN[module_name]() for module_name in modules + } + + if features: + device.features = { + feature_id: _mocked_feature(feature_id, require_fixture=True) + for feature_id in features + if isinstance(feature_id, str) + } + + device.features.update( + { + feature.id: feature + for feature in features + if isinstance(feature, Feature) + } + ) + device.children = [] + if children: + for child in children: + child.mac = mac + device.children = children + device.device_type = device_type if device_type else DeviceType.Unknown + if ( + not device_type + and device.children + and all( + child.device_type is DeviceType.StripSocket for child in device.children + ) + ): + device.device_type = DeviceType.Strip + + device.protocol = _mock_protocol() + device.config = device_config + device.credentials_hash = credentials_hash + return device + + +def _mocked_feature( + id: str, + *, + require_fixture=False, + value: Any = UNDEFINED, + name=None, + type_=None, + category=None, + precision_hint=None, + choices=None, + unit=None, + minimum_value=0, + maximum_value=2**16, # Arbitrary max +) -> Feature: + """Get a mocked feature. + + If kwargs are provided they will override the attributes for any features defined in fixtures.json + """ + feature = MagicMock(spec=Feature, name=f"Mocked {id} feature") + feature.id = id + feature.name = name or id.upper() + feature.set_value = AsyncMock() + if not (fixture := FEATURES_FIXTURE.get(id)): + assert ( + require_fixture is False + ), f"No fixture defined for feature {id} and require_fixture is True" + assert ( + value is not UNDEFINED + ), f"Value must be provided if feature {id} not defined in features.json" + fixture = {"value": value, "category": "Primary", "type": "Sensor"} + elif value is not UNDEFINED: + fixture["value"] = value + feature.value = fixture["value"] + + feature.type = type_ or Feature.Type[fixture["type"]] + feature.category = category or Feature.Category[fixture["category"]] + + # sensor + feature.precision_hint = precision_hint or fixture.get("precision_hint") + feature.unit = unit or fixture.get("unit") + + # number + feature.minimum_value = minimum_value or fixture.get("minimum_value") + feature.maximum_value = maximum_value or fixture.get("maximum_value") + + # select + feature.choices = choices or fixture.get("choices") + return feature + + +def _mocked_light_module() -> Light: + light = MagicMock(spec=Light, name="Mocked light module") + light.update = AsyncMock() + light.brightness = 50 + light.color_temp = 4000 + light.state = LightState( + light_on=True, brightness=light.brightness, color_temp=light.color_temp + ) + light.is_color = True + light.is_variable_color_temp = True + light.is_dimmable = True + light.is_brightness = True + light.has_effects = False + light.hsv = (10, 30, 5) + light.valid_temperature_range = ColorTempRange(min=4000, max=9000) + light.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} + light.set_state = AsyncMock() + light.set_brightness = AsyncMock() + light.set_hsv = AsyncMock() + light.set_color_temp = AsyncMock() + light.protocol = _mock_protocol() + return light + + +def _mocked_light_effect_module() -> LightEffect: + effect = MagicMock(spec=LightEffect, name="Mocked light effect") + effect.has_effects = True + effect.has_custom_effects = True + effect.effect = "Effect1" + effect.effect_list = ["Off", "Effect1", "Effect2"] + effect.set_effect = AsyncMock() + effect.set_custom_effect = AsyncMock() + return effect + + +def _mocked_fan_module() -> Fan: + fan = MagicMock(auto_spec=Fan, name="Mocked fan") + fan.fan_speed_level = 0 + fan.set_fan_speed_level = AsyncMock() + return fan + + +def _mocked_strip_children(features=None, alias=None) -> list[Device]: + plug0 = _mocked_device( + alias="Plug0" if alias is None else alias, + device_id="bb:bb:cc:dd:ee:ff_PLUG0DEVICEID", + mac="bb:bb:cc:dd:ee:ff", + device_type=DeviceType.StripSocket, + features=features, + ) + plug1 = _mocked_device( + alias="Plug1" if alias is None else alias, + device_id="cc:bb:cc:dd:ee:ff_PLUG1DEVICEID", + mac="cc:bb:cc:dd:ee:ff", + device_type=DeviceType.StripSocket, + features=features, + ) plug0.is_on = True - plug0.protocol = _mock_protocol() - plug1 = _mocked_plug() - plug1.device_id = "cc:bb:cc:dd:ee:ff_PLUG1DEVICEID" - plug1.mac = "cc:bb:cc:dd:ee:ff" - plug1.alias = "Plug1" - plug1.protocol = _mock_protocol() plug1.is_on = False - strip.children = [plug0, plug1] - return strip + return [plug0, plug1] + + +def _mocked_energy_features( + power=None, total=None, voltage=None, current=None, today=None +) -> list[Feature]: + feats = [] + if power is not None: + feats.append( + _mocked_feature( + "current_consumption", + value=power, + ) + ) + if total is not None: + feats.append( + _mocked_feature( + "consumption_total", + value=total, + ) + ) + if voltage is not None: + feats.append( + _mocked_feature( + "voltage", + value=voltage, + ) + ) + if current is not None: + feats.append( + _mocked_feature( + "current", + value=current, + ) + ) + # Today is always reported as 0 by the library rather than none + feats.append( + _mocked_feature( + "consumption_today", + value=today if today is not None else 0.0, + ) + ) + return feats + + +MODULE_TO_MOCK_GEN = { + Module.Light: _mocked_light_module, + Module.LightEffect: _mocked_light_effect_module, + Module.Fan: _mocked_fan_module, +} def _patch_discovery(device=None, no_device=False): async def _discovery(*args, **kwargs): if no_device: return {} - return {IP_ADDRESS: _mocked_bulb()} + return {IP_ADDRESS: _mocked_device()} return patch("homeassistant.components.tplink.Discover.discover", new=_discovery) @@ -281,8 +419,8 @@ def _patch_discovery(device=None, no_device=False): def _patch_single_discovery(device=None, no_device=False): async def _discover_single(*args, **kwargs): if no_device: - raise SmartDeviceException - return device if device else _mocked_bulb() + raise KasaException + return device if device else _mocked_device() return patch( "homeassistant.components.tplink.Discover.discover_single", new=_discover_single @@ -292,14 +430,14 @@ def _patch_single_discovery(device=None, no_device=False): def _patch_connect(device=None, no_device=False): async def _connect(*args, **kwargs): if no_device: - raise SmartDeviceException - return device if device else _mocked_bulb() + raise KasaException + return device if device else _mocked_device() - return patch("homeassistant.components.tplink.SmartDevice.connect", new=_connect) + return patch("homeassistant.components.tplink.Device.connect", new=_connect) async def initialize_config_entry_for_device( - hass: HomeAssistant, dev: SmartDevice + hass: HomeAssistant, dev: Device ) -> MockConfigEntry: """Create a mocked configuration entry for the given device. diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 88da9b699a7..f8d933de71e 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -17,7 +17,7 @@ from . import ( IP_ADDRESS2, MAC_ADDRESS, MAC_ADDRESS2, - _mocked_bulb, + _mocked_device, ) from tests.common import MockConfigEntry, mock_device_registry, mock_registry @@ -31,13 +31,13 @@ def mock_discovery(): discover=DEFAULT, discover_single=DEFAULT, ) as mock_discovery: - device = _mocked_bulb( + device = _mocked_device( device_config=copy.deepcopy(DEVICE_CONFIG_AUTH), credentials_hash=CREDENTIALS_HASH_AUTH, alias=None, ) devices = { - "127.0.0.1": _mocked_bulb( + "127.0.0.1": _mocked_device( device_config=copy.deepcopy(DEVICE_CONFIG_AUTH), credentials_hash=CREDENTIALS_HASH_AUTH, alias=None, @@ -52,12 +52,12 @@ def mock_discovery(): @pytest.fixture def mock_connect(): """Mock python-kasa connect.""" - with patch("homeassistant.components.tplink.SmartDevice.connect") as mock_connect: + with patch("homeassistant.components.tplink.Device.connect") as mock_connect: devices = { - IP_ADDRESS: _mocked_bulb( + IP_ADDRESS: _mocked_device( device_config=DEVICE_CONFIG_AUTH, credentials_hash=CREDENTIALS_HASH_AUTH ), - IP_ADDRESS2: _mocked_bulb( + IP_ADDRESS2: _mocked_device( device_config=DEVICE_CONFIG_AUTH, credentials_hash=CREDENTIALS_HASH_AUTH, mac=MAC_ADDRESS2, diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json new file mode 100644 index 00000000000..daf86a74643 --- /dev/null +++ b/tests/components/tplink/fixtures/features.json @@ -0,0 +1,287 @@ +{ + "state": { + "value": true, + "type": "Switch", + "category": "Primary" + }, + "led": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "auto_update_enabled": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "auto_off_enabled": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "smooth_transitions": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "frost_protection_enabled": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "fan_sleep_mode": { + "value": false, + "type": "Switch", + "category": "Config" + }, + "current_consumption": { + "value": 5.23, + "type": "Sensor", + "category": "Primary", + "unit": "W", + "precision_hint": 1 + }, + "consumption_today": { + "value": 5.23, + "type": "Sensor", + "category": "Info", + "unit": "kWh", + "precision_hint": 3 + }, + "consumption_this_month": { + "value": 15.345, + "type": "Sensor", + "category": "Info", + "unit": "kWh", + "precision_hint": 3 + }, + "consumption_total": { + "value": 30.0049, + "type": "Sensor", + "category": "Info", + "unit": "kWh", + "precision_hint": 3 + }, + "current": { + "value": 5.035, + "type": "Sensor", + "category": "Primary", + "unit": "A", + "precision_hint": 2 + }, + "voltage": { + "value": 121.1, + "type": "Sensor", + "category": "Primary", + "unit": "v", + "precision_hint": 1 + }, + "device_id": { + "value": "94hd2dn298812je12u0931828", + "type": "Sensor", + "category": "Debug" + }, + "signal_level": { + "value": 2, + "type": "Sensor", + "category": "Info" + }, + "rssi": { + "value": -62, + "type": "Sensor", + "category": "Debug" + }, + "ssid": { + "value": "HOMEWIFI", + "type": "Sensor", + "category": "Debug" + }, + "on_since": { + "value": "2024-06-24 10:03:11.046643+01:00", + "type": "Sensor", + "category": "Debug" + }, + "battery_level": { + "value": 85, + "type": "Sensor", + "category": "Info", + "unit": "%" + }, + "auto_off_at": { + "value": "2024-06-24 10:03:11.046643+01:00", + "type": "Sensor", + "category": "Info" + }, + "humidity": { + "value": 12, + "type": "Sensor", + "category": "Primary", + "unit": "%" + }, + "report_interval": { + "value": 16, + "type": "Sensor", + "category": "Debug", + "unit": "%" + }, + "alarm_source": { + "value": "", + "type": "Sensor", + "category": "Debug" + }, + "device_time": { + "value": "2024-06-24 10:03:11.046643+01:00", + "type": "Sensor", + "category": "Debug" + }, + "temperature": { + "value": 19.2, + "type": "Sensor", + "category": "Debug", + "unit": "celsius" + }, + "current_firmware_version": { + "value": "1.1.2", + "type": "Sensor", + "category": "Debug" + }, + "available_firmware_version": { + "value": "1.1.3", + "type": "Sensor", + "category": "Debug" + }, + "thermostat_mode": { + "value": "off", + "type": "Sensor", + "category": "Primary" + }, + "overheated": { + "value": false, + "type": "BinarySensor", + "category": "Info" + }, + "battery_low": { + "value": false, + "type": "BinarySensor", + "category": "Debug" + }, + "update_available": { + "value": false, + "type": "BinarySensor", + "category": "Info" + }, + "cloud_connection": { + "value": false, + "type": "BinarySensor", + "category": "Info" + }, + "temperature_warning": { + "value": false, + "type": "BinarySensor", + "category": "Debug" + }, + "humidity_warning": { + "value": false, + "type": "BinarySensor", + "category": "Debug" + }, + "water_alert": { + "value": false, + "type": "BinarySensor", + "category": "Primary" + }, + "is_open": { + "value": false, + "type": "BinarySensor", + "category": "Primary" + }, + "test_alarm": { + "value": "<Action>", + "type": "Action", + "category": "Config" + }, + "stop_alarm": { + "value": "<Action>", + "type": "Action", + "category": "Config" + }, + "smooth_transition_on": { + "value": false, + "type": "Number", + "category": "Config", + "minimum_value": 0, + "maximum_value": 60 + }, + "smooth_transition_off": { + "value": false, + "type": "Number", + "category": "Config", + "minimum_value": 0, + "maximum_value": 60 + }, + "auto_off_minutes": { + "value": false, + "type": "Number", + "category": "Config", + "unit": "min", + "minimum_value": 0, + "maximum_value": 60 + }, + "temperature_offset": { + "value": false, + "type": "Number", + "category": "Config", + "minimum_value": -10, + "maximum_value": 10 + }, + "target_temperature": { + "value": false, + "type": "Number", + "category": "Primary" + }, + "fan_speed_level": { + "value": 2, + "type": "Number", + "category": "Primary", + "minimum_value": 0, + "maximum_value": 4 + }, + "light_preset": { + "value": "Off", + "type": "Choice", + "category": "Config", + "choices": ["Off", "Preset 1", "Preset 2"] + }, + "alarm_sound": { + "value": "Phone Ring", + "type": "Choice", + "category": "Config", + "choices": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "alarm_volume": { + "value": "normal", + "type": "Choice", + "category": "Config", + "choices": ["low", "normal", "high"] + } +} diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..27b1372df27 --- /dev/null +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -0,0 +1,369 @@ +# serializer version: 1 +# name: test_states[binary_sensor.my_device_battery_low-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'binary_sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'binary_sensor.my_device_battery_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <BinarySensorDeviceClass.BATTERY: 'battery'>, + 'original_icon': None, + 'original_name': 'Battery low', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_low', + 'unique_id': '123456789ABCDEFGH_battery_low', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_cloud_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'binary_sensor.my_device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>, + 'original_icon': None, + 'original_name': 'Cloud connection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': '123456789ABCDEFGH_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_cloud_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'my_device Cloud connection', + }), + 'context': <ANY>, + 'entity_id': 'binary_sensor.my_device_cloud_connection', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[binary_sensor.my_device_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_device_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>, + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_open', + 'unique_id': '123456789ABCDEFGH_is_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'my_device Door', + }), + 'context': <ANY>, + 'entity_id': 'binary_sensor.my_device_door', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[binary_sensor.my_device_humidity_warning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'binary_sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'binary_sensor.my_device_humidity_warning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Humidity warning', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_warning', + 'unique_id': '123456789ABCDEFGH_humidity_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_moisture-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_device_moisture', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <BinarySensorDeviceClass.MOISTURE: 'moisture'>, + 'original_icon': None, + 'original_name': 'Moisture', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_alert', + 'unique_id': '123456789ABCDEFGH_water_alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_moisture-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'my_device Moisture', + }), + 'context': <ANY>, + 'entity_id': 'binary_sensor.my_device_moisture', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[binary_sensor.my_device_overheated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'binary_sensor.my_device_overheated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>, + 'original_icon': None, + 'original_name': 'Overheated', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overheated', + 'unique_id': '123456789ABCDEFGH_overheated', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_overheated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'my_device Overheated', + }), + 'context': <ANY>, + 'entity_id': 'binary_sensor.my_device_overheated', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[binary_sensor.my_device_temperature_warning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'binary_sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'binary_sensor.my_device_temperature_warning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature warning', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_warning', + 'unique_id': '123456789ABCDEFGH_temperature_warning', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'binary_sensor.my_device_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <BinarySensorDeviceClass.UPDATE: 'update'>, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'update_available', + 'unique_id': '123456789ABCDEFGH_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'my_device Update', + }), + 'context': <ANY>, + 'entity_id': 'binary_sensor.my_device_update', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[my_device-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'name': 'my_device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr new file mode 100644 index 00000000000..f26829101f7 --- /dev/null +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_states[button.my_device_stop_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'button.my_device_stop_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop alarm', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop_alarm', + 'unique_id': '123456789ABCDEFGH_stop_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_stop_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Stop alarm', + }), + 'context': <ANY>, + 'entity_id': 'button.my_device_stop_alarm', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'unknown', + }) +# --- +# name: test_states[button.my_device_test_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'button.my_device_test_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test alarm', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'test_alarm', + 'unique_id': '123456789ABCDEFGH_test_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_test_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Test alarm', + }), + 'context': <ANY>, + 'entity_id': 'button.my_device_test_alarm', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'unknown', + }) +# --- +# name: test_states[my_device-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'name': 'my_device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr new file mode 100644 index 00000000000..d30f8cd3532 --- /dev/null +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -0,0 +1,94 @@ +# serializer version: 1 +# name: test_states[climate.thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + <HVACMode.HEAT: 'heat'>, + <HVACMode.OFF: 'off'>, + ]), + 'max_temp': 65536, + 'min_temp': None, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': <ClimateEntityFeature: 385>, + 'translation_key': None, + 'unique_id': '123456789ABCDEFGH_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[climate.thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20, + 'friendly_name': 'thermostat', + 'hvac_action': <HVACAction.HEATING: 'heating'>, + 'hvac_modes': list([ + <HVACMode.HEAT: 'heat'>, + <HVACMode.OFF: 'off'>, + ]), + 'max_temp': 65536, + 'min_temp': None, + 'supported_features': <ClimateEntityFeature: 385>, + 'temperature': 22, + }), + 'context': <ANY>, + 'entity_id': 'climate.thermostat', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'heat', + }) +# --- +# name: test_states[thermostat-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'name': 'thermostat', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr new file mode 100644 index 00000000000..d692abdce03 --- /dev/null +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -0,0 +1,194 @@ +# serializer version: 1 +# name: test_states[fan.my_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.my_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': <FanEntityFeature: 1>, + 'translation_key': None, + 'unique_id': '123456789ABCDEFGH', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[fan.my_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device', + 'percentage': None, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': <FanEntityFeature: 1>, + }), + 'context': <ANY>, + 'entity_id': 'fan.my_device', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[fan.my_device_my_fan_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.my_device_my_fan_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'my_fan_0', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': <FanEntityFeature: 1>, + 'translation_key': None, + 'unique_id': '123456789ABCDEFGH00', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[fan.my_device_my_fan_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device my_fan_0', + 'percentage': None, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': <FanEntityFeature: 1>, + }), + 'context': <ANY>, + 'entity_id': 'fan.my_device_my_fan_0', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[fan.my_device_my_fan_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.my_device_my_fan_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'my_fan_1', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': <FanEntityFeature: 1>, + 'translation_key': None, + 'unique_id': '123456789ABCDEFGH01', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[fan.my_device_my_fan_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device my_fan_1', + 'percentage': None, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': <FanEntityFeature: 1>, + }), + 'context': <ANY>, + 'entity_id': 'fan.my_device_my_fan_1', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[my_device-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'name': 'my_device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr new file mode 100644 index 00000000000..9bfc9c0126a --- /dev/null +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -0,0 +1,255 @@ +# serializer version: 1 +# name: test_states[my_device-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'name': 'my_device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[number.my_device_smooth_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': <NumberMode.AUTO: 'auto'>, + 'step': 1.0, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'number.my_device_smooth_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smooth off', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smooth_transition_off', + 'unique_id': '123456789ABCDEFGH_smooth_transition_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_smooth_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Smooth off', + 'max': 65536, + 'min': 0, + 'mode': <NumberMode.AUTO: 'auto'>, + 'step': 1.0, + }), + 'context': <ANY>, + 'entity_id': 'number.my_device_smooth_off', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'False', + }) +# --- +# name: test_states[number.my_device_smooth_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': <NumberMode.AUTO: 'auto'>, + 'step': 1.0, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'number.my_device_smooth_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smooth on', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smooth_transition_on', + 'unique_id': '123456789ABCDEFGH_smooth_transition_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_smooth_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Smooth on', + 'max': 65536, + 'min': 0, + 'mode': <NumberMode.AUTO: 'auto'>, + 'step': 1.0, + }), + 'context': <ANY>, + 'entity_id': 'number.my_device_smooth_on', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'False', + }) +# --- +# name: test_states[number.my_device_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': -10, + 'mode': <NumberMode.AUTO: 'auto'>, + 'step': 1.0, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'number.my_device_temperature_offset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature offset', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '123456789ABCDEFGH_temperature_offset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Temperature offset', + 'max': 65536, + 'min': -10, + 'mode': <NumberMode.AUTO: 'auto'>, + 'step': 1.0, + }), + 'context': <ANY>, + 'entity_id': 'number.my_device_temperature_offset', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'False', + }) +# --- +# name: test_states[number.my_device_turn_off_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': <NumberMode.AUTO: 'auto'>, + 'step': 1.0, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'number.my_device_turn_off_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Turn off in', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_off_minutes', + 'unique_id': '123456789ABCDEFGH_auto_off_minutes', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_turn_off_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Turn off in', + 'max': 65536, + 'min': 0, + 'mode': <NumberMode.AUTO: 'auto'>, + 'step': 1.0, + }), + 'context': <ANY>, + 'entity_id': 'number.my_device_turn_off_in', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'False', + }) +# --- diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr new file mode 100644 index 00000000000..2cf02415238 --- /dev/null +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -0,0 +1,238 @@ +# serializer version: 1 +# name: test_states[my_device-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'name': 'my_device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[select.my_device_alarm_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Doorbell Ring 1', + 'Doorbell Ring 2', + 'Doorbell Ring 3', + 'Doorbell Ring 4', + 'Doorbell Ring 5', + 'Doorbell Ring 6', + 'Doorbell Ring 7', + 'Doorbell Ring 8', + 'Doorbell Ring 9', + 'Doorbell Ring 10', + 'Phone Ring', + 'Alarm 1', + 'Alarm 2', + 'Alarm 3', + 'Alarm 4', + 'Dripping Tap', + 'Alarm 5', + 'Connection 1', + 'Connection 2', + ]), + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'select.my_device_alarm_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm sound', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_sound', + 'unique_id': '123456789ABCDEFGH_alarm_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[select.my_device_alarm_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Alarm sound', + 'options': list([ + 'Doorbell Ring 1', + 'Doorbell Ring 2', + 'Doorbell Ring 3', + 'Doorbell Ring 4', + 'Doorbell Ring 5', + 'Doorbell Ring 6', + 'Doorbell Ring 7', + 'Doorbell Ring 8', + 'Doorbell Ring 9', + 'Doorbell Ring 10', + 'Phone Ring', + 'Alarm 1', + 'Alarm 2', + 'Alarm 3', + 'Alarm 4', + 'Dripping Tap', + 'Alarm 5', + 'Connection 1', + 'Connection 2', + ]), + }), + 'context': <ANY>, + 'entity_id': 'select.my_device_alarm_sound', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'Phone Ring', + }) +# --- +# name: test_states[select.my_device_alarm_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'normal', + 'high', + ]), + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'select.my_device_alarm_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm volume', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_volume', + 'unique_id': '123456789ABCDEFGH_alarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[select.my_device_alarm_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Alarm volume', + 'options': list([ + 'low', + 'normal', + 'high', + ]), + }), + 'context': <ANY>, + 'entity_id': 'select.my_device_alarm_volume', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'normal', + }) +# --- +# name: test_states[select.my_device_light_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Preset 1', + 'Preset 2', + ]), + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'select.my_device_light_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light preset', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_preset', + 'unique_id': '123456789ABCDEFGH_light_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[select.my_device_light_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Light preset', + 'options': list([ + 'Off', + 'Preset 1', + 'Preset 2', + ]), + }), + 'context': <ANY>, + 'entity_id': 'select.my_device_light_preset', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'Off', + }) +# --- diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..cd8980bf57f --- /dev/null +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -0,0 +1,790 @@ +# serializer version: 1 +# name: test_states[my_device-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'name': 'my_device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[sensor.my_device_alarm_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_alarm_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm source', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_source', + 'unique_id': '123456789ABCDEFGH_alarm_source', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_auto_off_at-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_auto_off_at', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>, + 'original_icon': None, + 'original_name': 'Auto off at', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_off_at', + 'unique_id': '123456789ABCDEFGH_auto_off_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_auto_off_at-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'my_device Auto off at', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_auto_off_at', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '2024-06-24T09:03:11+00:00', + }) +# --- +# name: test_states[sensor.my_device_battery_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>, + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': '123456789ABCDEFGH_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.my_device_battery_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'my_device Battery level', + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + 'unit_of_measurement': '%', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_battery_level', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '85', + }) +# --- +# name: test_states[sensor.my_device_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': <SensorDeviceClass.CURRENT: 'current'>, + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': '123456789ABCDEFGH_current_a', + 'unit_of_measurement': 'A', + }) +# --- +# name: test_states[sensor.my_device_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'my_device Current', + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + 'unit_of_measurement': 'A', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_current', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '5.04', + }) +# --- +# name: test_states[sensor.my_device_current_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_device_current_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': <SensorDeviceClass.POWER: 'power'>, + 'original_icon': None, + 'original_name': 'Current consumption', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_consumption', + 'unique_id': '123456789ABCDEFGH_current_power_w', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_states[sensor.my_device_current_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'my_device Current consumption', + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + 'unit_of_measurement': 'W', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_current_consumption', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '5.2', + }) +# --- +# name: test_states[sensor.my_device_device_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_device_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>, + 'original_icon': None, + 'original_name': 'Device time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_time', + 'unique_id': '123456789ABCDEFGH_device_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_device_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>, + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '123456789ABCDEFGH_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.my_device_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'my_device Humidity', + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + 'unit_of_measurement': '%', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_humidity', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '12', + }) +# --- +# name: test_states[sensor.my_device_on_since-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_on_since', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>, + 'original_icon': None, + 'original_name': 'On since', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'on_since', + 'unique_id': '123456789ABCDEFGH_on_since', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_report_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_report_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <SensorDeviceClass.DURATION: 'duration'>, + 'original_icon': None, + 'original_name': 'Report interval', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'report_interval', + 'unique_id': '123456789ABCDEFGH_report_interval', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.my_device_signal_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_signal_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Signal level', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'signal_level', + 'unique_id': '123456789ABCDEFGH_signal_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_signal_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Signal level', + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_signal_level', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '2', + }) +# --- +# name: test_states[sensor.my_device_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>, + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': '123456789ABCDEFGH_rssi', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_ssid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SSID', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ssid', + 'unique_id': '123456789ABCDEFGH_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': <RegistryEntryDisabler.INTEGRATION: 'integration'>, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>, + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '123456789ABCDEFGH_temperature', + 'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>, + }) +# --- +# name: test_states[sensor.my_device_this_month_s_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_this_month_s_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>, + 'original_icon': None, + 'original_name': "This month's consumption", + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_this_month', + 'unique_id': '123456789ABCDEFGH_consumption_this_month', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_states[sensor.my_device_this_month_s_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': "my_device This month's consumption", + 'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>, + 'unit_of_measurement': 'kWh', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_this_month_s_consumption', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '15.345', + }) +# --- +# name: test_states[sensor.my_device_today_s_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_today_s_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>, + 'original_icon': None, + 'original_name': "Today's consumption", + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_today', + 'unique_id': '123456789ABCDEFGH_today_energy_kwh', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_states[sensor.my_device_today_s_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': "my_device Today's consumption", + 'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>, + 'unit_of_measurement': 'kWh', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_today_s_consumption', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '5.23', + }) +# --- +# name: test_states[sensor.my_device_total_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>, + 'entity_id': 'sensor.my_device_total_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>, + 'original_icon': None, + 'original_name': 'Total consumption', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_total', + 'unique_id': '123456789ABCDEFGH_total_energy_kwh', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_states[sensor.my_device_total_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'my_device Total consumption', + 'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>, + 'unit_of_measurement': 'kWh', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_total_consumption', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '30.005', + }) +# --- +# name: test_states[sensor.my_device_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_device_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>, + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': '123456789ABCDEFGH_voltage', + 'unit_of_measurement': 'v', + }) +# --- +# name: test_states[sensor.my_device_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'my_device Voltage', + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + 'unit_of_measurement': 'v', + }), + 'context': <ANY>, + 'entity_id': 'sensor.my_device_voltage', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': '121.1', + }) +# --- diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr new file mode 100644 index 00000000000..2fe1f6e6b08 --- /dev/null +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -0,0 +1,311 @@ +# serializer version: 1 +# name: test_states[my_device-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': <ANY>, + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': <ANY>, + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'name': 'my_device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[switch.my_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.my_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABCDEFGH', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device', + }), + 'context': <ANY>, + 'entity_id': 'switch.my_device', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'on', + }) +# --- +# name: test_states[switch.my_device_auto_off_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'switch.my_device_auto_off_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto off enabled', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_off_enabled', + 'unique_id': '123456789ABCDEFGH_auto_off_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_auto_off_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Auto off enabled', + }), + 'context': <ANY>, + 'entity_id': 'switch.my_device_auto_off_enabled', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'on', + }) +# --- +# name: test_states[switch.my_device_auto_update_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'switch.my_device_auto_update_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto update enabled', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_update_enabled', + 'unique_id': '123456789ABCDEFGH_auto_update_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_auto_update_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Auto update enabled', + }), + 'context': <ANY>, + 'entity_id': 'switch.my_device_auto_update_enabled', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'on', + }) +# --- +# name: test_states[switch.my_device_fan_sleep_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'switch.my_device_fan_sleep_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan sleep mode', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_sleep_mode', + 'unique_id': '123456789ABCDEFGH_fan_sleep_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_fan_sleep_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Fan sleep mode', + }), + 'context': <ANY>, + 'entity_id': 'switch.my_device_fan_sleep_mode', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- +# name: test_states[switch.my_device_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'switch.my_device_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led', + 'unique_id': '123456789ABCDEFGH_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device LED', + }), + 'context': <ANY>, + 'entity_id': 'switch.my_device_led', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'on', + }) +# --- +# name: test_states[switch.my_device_smooth_transitions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': <EntityCategory.CONFIG: 'config'>, + 'entity_id': 'switch.my_device_smooth_transitions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smooth transitions', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smooth_transitions', + 'unique_id': '123456789ABCDEFGH_smooth_transitions', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_smooth_transitions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Smooth transitions', + }), + 'context': <ANY>, + 'entity_id': 'switch.my_device_smooth_transitions', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'on', + }) +# --- diff --git a/tests/components/tplink/test_binary_sensor.py b/tests/components/tplink/test_binary_sensor.py new file mode 100644 index 00000000000..e2b9cd08d13 --- /dev/null +++ b/tests/components/tplink/test_binary_sensor.py @@ -0,0 +1,124 @@ +"""Tests for tplink binary_sensor platform.""" + +from kasa import Feature +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import tplink +from homeassistant.components.tplink.binary_sensor import BINARY_SENSOR_DESCRIPTIONS +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.entity import EXCLUDED_FEATURES +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + DEVICE_ID, + MAC_ADDRESS, + _mocked_device, + _mocked_feature, + _mocked_strip_children, + _patch_connect, + _patch_discovery, + setup_platform_for_device, + snapshot_platform, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mocked_feature_binary_sensor() -> Feature: + """Return mocked tplink binary sensor feature.""" + return _mocked_feature( + "overheated", + value=False, + name="Overheated", + type_=Feature.Type.BinarySensor, + category=Feature.Category.Primary, + ) + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a sensor unique ids.""" + features = {description.key for description in BINARY_SENSOR_DESCRIPTIONS} + features.update(EXCLUDED_FEATURES) + device = _mocked_device(alias="my_device", features=features) + + await setup_platform_for_device( + hass, mock_config_entry, Platform.BINARY_SENSOR, device + ) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + for excluded in EXCLUDED_FEATURES: + assert hass.states.get(f"sensor.my_device_{excluded}") is None + + +async def test_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mocked_feature_binary_sensor: Feature, +) -> None: + """Test a sensor unique ids.""" + mocked_feature = mocked_feature_binary_sensor + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + + plug = _mocked_device(alias="my_plug", features=[mocked_feature]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + # The entity_id is based on standard name from core. + entity_id = "binary_sensor.my_plug_overheated" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_{mocked_feature.id}" + + +async def test_binary_sensor_children( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mocked_feature_binary_sensor: Feature, +) -> None: + """Test a sensor unique ids.""" + mocked_feature = mocked_feature_binary_sensor + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_device( + alias="my_plug", + features=[mocked_feature], + children=_mocked_strip_children(features=[mocked_feature]), + ) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "binary_sensor.my_plug_overheated" + entity = entity_registry.async_get(entity_id) + assert entity + device = device_registry.async_get(entity.device_id) + + for plug_id in range(2): + child_entity_id = f"binary_sensor.my_plug_plug{plug_id}_overheated" + child_entity = entity_registry.async_get(child_entity_id) + assert child_entity + assert child_entity.unique_id == f"PLUG{plug_id}DEVICEID_{mocked_feature.id}" + assert child_entity.device_id != entity.device_id + child_device = device_registry.async_get(child_entity.device_id) + assert child_device + assert child_device.via_device_id == device.id diff --git a/tests/components/tplink/test_button.py b/tests/components/tplink/test_button.py new file mode 100644 index 00000000000..143a882a6cb --- /dev/null +++ b/tests/components/tplink/test_button.py @@ -0,0 +1,153 @@ +"""Tests for tplink button platform.""" + +from kasa import Feature +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import tplink +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.tplink.button import BUTTON_DESCRIPTIONS +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.entity import EXCLUDED_FEATURES +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + DEVICE_ID, + MAC_ADDRESS, + _mocked_device, + _mocked_feature, + _mocked_strip_children, + _patch_connect, + _patch_discovery, + setup_platform_for_device, + snapshot_platform, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mocked_feature_button() -> Feature: + """Return mocked tplink binary sensor feature.""" + return _mocked_feature( + "test_alarm", + value="<Action>", + name="Test alarm", + type_=Feature.Type.Action, + category=Feature.Category.Primary, + ) + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a sensor unique ids.""" + features = {description.key for description in BUTTON_DESCRIPTIONS} + features.update(EXCLUDED_FEATURES) + device = _mocked_device(alias="my_device", features=features) + + await setup_platform_for_device(hass, mock_config_entry, Platform.BUTTON, device) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + for excluded in EXCLUDED_FEATURES: + assert hass.states.get(f"sensor.my_device_{excluded}") is None + + +async def test_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mocked_feature_button: Feature, +) -> None: + """Test a sensor unique ids.""" + mocked_feature = mocked_feature_button + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + + plug = _mocked_device(alias="my_plug", features=[mocked_feature]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + # The entity_id is based on standard name from core. + entity_id = "button.my_plug_test_alarm" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_{mocked_feature.id}" + + +async def test_button_children( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mocked_feature_button: Feature, +) -> None: + """Test a sensor unique ids.""" + mocked_feature = mocked_feature_button + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_device( + alias="my_plug", + features=[mocked_feature], + children=_mocked_strip_children(features=[mocked_feature]), + ) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "button.my_plug_test_alarm" + entity = entity_registry.async_get(entity_id) + assert entity + device = device_registry.async_get(entity.device_id) + + for plug_id in range(2): + child_entity_id = f"button.my_plug_plug{plug_id}_test_alarm" + child_entity = entity_registry.async_get(child_entity_id) + assert child_entity + assert child_entity.unique_id == f"PLUG{plug_id}DEVICEID_{mocked_feature.id}" + assert child_entity.device_id != entity.device_id + child_device = device_registry.async_get(child_entity.device_id) + assert child_device + assert child_device.via_device_id == device.id + + +async def test_button_press( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mocked_feature_button: Feature, +) -> None: + """Test a number entity limits and setting values.""" + mocked_feature = mocked_feature_button + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_device(alias="my_plug", features=[mocked_feature]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "button.my_plug_test_alarm" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_test_alarm" + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mocked_feature.set_value.assert_called_with(True) diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py new file mode 100644 index 00000000000..a80a74a5697 --- /dev/null +++ b/tests/components/tplink/test_climate.py @@ -0,0 +1,226 @@ +"""Tests for tplink climate platform.""" + +from datetime import timedelta + +from kasa import Device, Feature +from kasa.smart.modules.temperaturecontrol import ThermostatState +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util + +from . import ( + DEVICE_ID, + _mocked_device, + _mocked_feature, + setup_platform_for_device, + snapshot_platform, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + +ENTITY_ID = "climate.thermostat" + + +@pytest.fixture +async def mocked_hub(hass: HomeAssistant) -> Device: + """Return mocked tplink binary sensor feature.""" + + features = [ + _mocked_feature( + "temperature", value=20, category=Feature.Category.Primary, unit="celsius" + ), + _mocked_feature( + "target_temperature", + value=22, + type_=Feature.Type.Number, + category=Feature.Category.Primary, + unit="celsius", + ), + _mocked_feature( + "state", + value=True, + type_=Feature.Type.Switch, + category=Feature.Category.Primary, + ), + _mocked_feature( + "thermostat_mode", + value=ThermostatState.Heating, + type_=Feature.Type.Choice, + category=Feature.Category.Primary, + ), + ] + + thermostat = _mocked_device( + alias="thermostat", features=features, device_type=Device.Type.Thermostat + ) + + return _mocked_device( + alias="hub", children=[thermostat], device_type=Device.Type.Hub + ) + + +async def test_climate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mocked_hub: Device, +) -> None: + """Test initialization.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + + entity = entity_registry.async_get(ENTITY_ID) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_climate" + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] is HVACAction.HEATING + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20 + assert state.attributes[ATTR_TEMPERATURE] == 22 + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mocked_hub: Device, +) -> None: + """Snapshot test.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + +async def test_set_temperature( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mocked_hub: Device +) -> None: + """Test that set_temperature service calls the setter.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + + mocked_thermostat = mocked_hub.children[0] + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 10}, + blocking=True, + ) + target_temp_feature = mocked_thermostat.features["target_temperature"] + target_temp_feature.set_value.assert_called_with(10) + + +async def test_set_hvac_mode( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mocked_hub: Device +) -> None: + """Test that set_hvac_mode service works.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + + mocked_thermostat = mocked_hub.children[0] + mocked_state = mocked_thermostat.features["state"] + assert mocked_state is not None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + mocked_state.set_value.assert_called_with(False) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + mocked_state.set_value.assert_called_with(True) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_HVAC_MODE: HVACMode.DRY}, + blocking=True, + ) + + +async def test_turn_on_and_off( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mocked_hub: Device +) -> None: + """Test that turn_on and turn_off services work as expected.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + + mocked_thermostat = mocked_hub.children[0] + mocked_state = mocked_thermostat.features["state"] + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [ENTITY_ID]}, + blocking=True, + ) + + mocked_state.set_value.assert_called_with(False) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [ENTITY_ID]}, + blocking=True, + ) + + mocked_state.set_value.assert_called_with(True) + + +async def test_unknown_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that unknown device modes log a warning and default to off.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.CLIMATE, mocked_hub + ) + + mocked_thermostat = mocked_hub.children[0] + mocked_state = mocked_thermostat.features["thermostat_mode"] + mocked_state.value = ThermostatState.Unknown + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + assert "Unknown thermostat state, defaulting to OFF" in caplog.text diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 7bf3b8cce5e..7560ff4a72d 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -2,17 +2,17 @@ from unittest.mock import AsyncMock, patch -from kasa import TimeoutException +from kasa import TimeoutError import pytest from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.tplink import ( DOMAIN, - AuthenticationException, + AuthenticationError, Credentials, DeviceConfig, - SmartDeviceException, + KasaException, ) from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG from homeassistant.config_entries import ConfigEntryState @@ -40,7 +40,7 @@ from . import ( MAC_ADDRESS, MAC_ADDRESS2, MODULE, - _mocked_bulb, + _mocked_device, _patch_connect, _patch_discovery, _patch_single_discovery, @@ -120,7 +120,7 @@ async def test_discovery_auth( ) -> None: """Test authenticated discovery.""" - mock_discovery["mock_device"].update.side_effect = AuthenticationException + mock_discovery["mock_device"].update.side_effect = AuthenticationError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -155,8 +155,8 @@ async def test_discovery_auth( @pytest.mark.parametrize( ("error_type", "errors_msg", "error_placement"), [ - (AuthenticationException("auth_error_details"), "invalid_auth", CONF_PASSWORD), - (SmartDeviceException("smart_device_error_details"), "cannot_connect", "base"), + (AuthenticationError("auth_error_details"), "invalid_auth", CONF_PASSWORD), + (KasaException("smart_device_error_details"), "cannot_connect", "base"), ], ids=["invalid-auth", "unknown-error"], ) @@ -170,7 +170,7 @@ async def test_discovery_auth_errors( error_placement, ) -> None: """Test handling of discovery authentication errors.""" - mock_discovery["mock_device"].update.side_effect = AuthenticationException + mock_discovery["mock_device"].update.side_effect = AuthenticationError default_connect_side_effect = mock_connect["connect"].side_effect mock_connect["connect"].side_effect = error_type @@ -223,7 +223,7 @@ async def test_discovery_new_credentials( mock_init, ) -> None: """Test setting up discovery with new credentials.""" - mock_discovery["mock_device"].update.side_effect = AuthenticationException + mock_discovery["mock_device"].update.side_effect = AuthenticationError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -272,10 +272,10 @@ async def test_discovery_new_credentials_invalid( mock_init, ) -> None: """Test setting up discovery with new invalid credentials.""" - mock_discovery["mock_device"].update.side_effect = AuthenticationException + mock_discovery["mock_device"].update.side_effect = AuthenticationError default_connect_side_effect = mock_connect["connect"].side_effect - mock_connect["connect"].side_effect = AuthenticationException + mock_connect["connect"].side_effect = AuthenticationError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -514,7 +514,7 @@ async def test_manual_auth( assert result["step_id"] == "user" assert not result["errors"] - mock_discovery["mock_device"].update.side_effect = AuthenticationException + mock_discovery["mock_device"].update.side_effect = AuthenticationError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} @@ -544,8 +544,8 @@ async def test_manual_auth( @pytest.mark.parametrize( ("error_type", "errors_msg", "error_placement"), [ - (AuthenticationException("auth_error_details"), "invalid_auth", CONF_PASSWORD), - (SmartDeviceException("smart_device_error_details"), "cannot_connect", "base"), + (AuthenticationError("auth_error_details"), "invalid_auth", CONF_PASSWORD), + (KasaException("smart_device_error_details"), "cannot_connect", "base"), ], ids=["invalid-auth", "unknown-error"], ) @@ -566,7 +566,7 @@ async def test_manual_auth_errors( assert result["step_id"] == "user" assert not result["errors"] - mock_discovery["mock_device"].update.side_effect = AuthenticationException + mock_discovery["mock_device"].update.side_effect = AuthenticationError default_connect_side_effect = mock_connect["connect"].side_effect mock_connect["connect"].side_effect = error_type @@ -765,7 +765,7 @@ async def test_integration_discovery_with_ip_change( mock_connect: AsyncMock, ) -> None: """Test reauth flow.""" - mock_connect["connect"].side_effect = SmartDeviceException() + mock_connect["connect"].side_effect = KasaException() mock_config_entry.add_to_hass(hass) with patch("homeassistant.components.tplink.Discover.discover", return_value={}): await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -797,7 +797,7 @@ async def test_integration_discovery_with_ip_change( config = DeviceConfig.from_dict(DEVICE_CONFIG_DICT_AUTH) mock_connect["connect"].reset_mock(side_effect=True) - bulb = _mocked_bulb( + bulb = _mocked_device( device_config=config, mac=mock_config_entry.unique_id, ) @@ -818,7 +818,7 @@ async def test_dhcp_discovery_with_ip_change( mock_connect: AsyncMock, ) -> None: """Test dhcp discovery with an IP change.""" - mock_connect["connect"].side_effect = SmartDeviceException() + mock_connect["connect"].side_effect = KasaException() mock_config_entry.add_to_hass(hass) with patch("homeassistant.components.tplink.Discover.discover", return_value={}): await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -883,7 +883,7 @@ async def test_reauth_update_from_discovery( mock_connect: AsyncMock, ) -> None: """Test reauth flow.""" - mock_connect["connect"].side_effect = AuthenticationException + mock_connect["connect"].side_effect = AuthenticationError mock_config_entry.add_to_hass(hass) with patch("homeassistant.components.tplink.Discover.discover", return_value={}): await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -920,7 +920,7 @@ async def test_reauth_update_from_discovery_with_ip_change( mock_connect: AsyncMock, ) -> None: """Test reauth flow.""" - mock_connect["connect"].side_effect = AuthenticationException() + mock_connect["connect"].side_effect = AuthenticationError() mock_config_entry.add_to_hass(hass) with patch("homeassistant.components.tplink.Discover.discover", return_value={}): await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -957,7 +957,7 @@ async def test_reauth_no_update_if_config_and_ip_the_same( mock_connect: AsyncMock, ) -> None: """Test reauth discovery does not update when the host and config are the same.""" - mock_connect["connect"].side_effect = AuthenticationException() + mock_connect["connect"].side_effect = AuthenticationError() mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( mock_config_entry, @@ -996,8 +996,8 @@ async def test_reauth_no_update_if_config_and_ip_the_same( @pytest.mark.parametrize( ("error_type", "errors_msg", "error_placement"), [ - (AuthenticationException("auth_error_details"), "invalid_auth", CONF_PASSWORD), - (SmartDeviceException("smart_device_error_details"), "cannot_connect", "base"), + (AuthenticationError("auth_error_details"), "invalid_auth", CONF_PASSWORD), + (KasaException("smart_device_error_details"), "cannot_connect", "base"), ], ids=["invalid-auth", "unknown-error"], ) @@ -1060,8 +1060,8 @@ async def test_reauth_errors( @pytest.mark.parametrize( ("error_type", "expected_flow"), [ - (AuthenticationException, FlowResultType.FORM), - (SmartDeviceException, FlowResultType.ABORT), + (AuthenticationError, FlowResultType.FORM), + (KasaException, FlowResultType.ABORT), ], ids=["invalid-auth", "unknown-error"], ) @@ -1119,7 +1119,7 @@ async def test_discovery_timeout_connect( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_discovery["discover_single"].side_effect = TimeoutException + mock_discovery["discover_single"].side_effect = TimeoutError await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -1149,7 +1149,7 @@ async def test_reauth_update_other_flows( unique_id=MAC_ADDRESS2, ) default_side_effect = mock_connect["connect"].side_effect - mock_connect["connect"].side_effect = AuthenticationException() + mock_connect["connect"].side_effect = AuthenticationError() mock_config_entry.add_to_hass(hass) mock_config_entry2.add_to_hass(hass) with patch("homeassistant.components.tplink.Discover.discover", return_value={}): diff --git a/tests/components/tplink/test_diagnostics.py b/tests/components/tplink/test_diagnostics.py index 3543cf95572..7288d631f4a 100644 --- a/tests/components/tplink/test_diagnostics.py +++ b/tests/components/tplink/test_diagnostics.py @@ -2,12 +2,12 @@ import json -from kasa import SmartDevice +from kasa import Device import pytest from homeassistant.core import HomeAssistant -from . import _mocked_bulb, _mocked_plug, initialize_config_entry_for_device +from . import _mocked_device, initialize_config_entry_for_device from tests.common import load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -18,13 +18,13 @@ from tests.typing import ClientSessionGenerator ("mocked_dev", "fixture_file", "sysinfo_vars", "expected_oui"), [ ( - _mocked_bulb(), + _mocked_device(), "tplink-diagnostics-data-bulb-kl130.json", ["mic_mac", "deviceId", "oemId", "hwId", "alias"], "AA:BB:CC", ), ( - _mocked_plug(), + _mocked_device(), "tplink-diagnostics-data-plug-hs110.json", ["mac", "deviceId", "oemId", "hwId", "alias", "longitude_i", "latitude_i"], "AA:BB:CC", @@ -34,7 +34,7 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mocked_dev: SmartDevice, + mocked_dev: Device, fixture_file: str, sysinfo_vars: list[str], expected_oui: str | None, diff --git a/tests/components/tplink/test_fan.py b/tests/components/tplink/test_fan.py new file mode 100644 index 00000000000..deba33abfa5 --- /dev/null +++ b/tests/components/tplink/test_fan.py @@ -0,0 +1,154 @@ +"""Tests for fan platform.""" + +from __future__ import annotations + +from datetime import timedelta + +from kasa import Device, Module +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.util.dt as dt_util + +from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a fan state.""" + child_fan_1 = _mocked_device( + modules=[Module.Fan], alias="my_fan_0", device_id=f"{DEVICE_ID}00" + ) + child_fan_2 = _mocked_device( + modules=[Module.Fan], alias="my_fan_1", device_id=f"{DEVICE_ID}01" + ) + parent_device = _mocked_device( + device_id=DEVICE_ID, + alias="my_device", + children=[child_fan_1, child_fan_2], + modules=[Module.Fan], + device_type=Device.Type.WallSwitch, + ) + + await setup_platform_for_device( + hass, mock_config_entry, Platform.FAN, parent_device + ) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + +async def test_fan_unique_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a fan unique id.""" + fan = _mocked_device(modules=[Module.Fan], alias="my_fan") + await setup_platform_for_device(hass, mock_config_entry, Platform.FAN, fan) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert device_entries + entity_id = "fan.my_fan" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == DEVICE_ID + + +async def test_fan(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Test a color fan and that all transitions are correctly passed.""" + device = _mocked_device(modules=[Module.Fan], alias="my_fan") + fan = device.modules[Module.Fan] + fan.fan_speed_level = 0 + await setup_platform_for_device(hass, mock_config_entry, Platform.FAN, device) + + entity_id = "fan.my_fan" + + state = hass.states.get(entity_id) + assert state.state == "off" + + await hass.services.async_call( + FAN_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + fan.set_fan_speed_level.assert_called_once_with(4) + fan.set_fan_speed_level.reset_mock() + + fan.fan_speed_level = 4 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(entity_id) + assert state.state == "on" + + await hass.services.async_call( + FAN_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + fan.set_fan_speed_level.assert_called_once_with(0) + fan.set_fan_speed_level.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + fan.set_fan_speed_level.assert_called_once_with(2) + fan.set_fan_speed_level.reset_mock() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 25}, + blocking=True, + ) + fan.set_fan_speed_level.assert_called_once_with(1) + fan.set_fan_speed_level.reset_mock() + + +async def test_fan_child( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test child fans are added to parent device with the right ids.""" + child_fan_1 = _mocked_device( + modules=[Module.Fan], alias="my_fan_0", device_id=f"{DEVICE_ID}00" + ) + child_fan_2 = _mocked_device( + modules=[Module.Fan], alias="my_fan_1", device_id=f"{DEVICE_ID}01" + ) + parent_device = _mocked_device( + device_id=DEVICE_ID, + alias="my_device", + children=[child_fan_1, child_fan_2], + modules=[Module.Fan], + device_type=Device.Type.WallSwitch, + ) + await setup_platform_for_device( + hass, mock_config_entry, Platform.FAN, parent_device + ) + + entity_id = "fan.my_device" + entity = entity_registry.async_get(entity_id) + assert entity + + for fan_id in range(2): + child_entity_id = f"fan.my_device_my_fan_{fan_id}" + child_entity = entity_registry.async_get(child_entity_id) + assert child_entity + assert child_entity.unique_id == f"{DEVICE_ID}0{fan_id}" + assert child_entity.device_id == entity.device_id diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 481a9e0e2b3..61ec9decc10 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -4,10 +4,10 @@ from __future__ import annotations import copy from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from kasa.exceptions import AuthenticationException +from kasa import AuthenticationError, Feature, KasaException, Module import pytest from homeassistant import setup @@ -21,19 +21,20 @@ from homeassistant.const import ( CONF_USERNAME, STATE_ON, STATE_UNAVAILABLE, + EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( CREATE_ENTRY_DATA_AUTH, + CREATE_ENTRY_DATA_LEGACY, DEVICE_CONFIG_AUTH, IP_ADDRESS, MAC_ADDRESS, - _mocked_dimmer, - _mocked_plug, + _mocked_device, _patch_connect, _patch_discovery, _patch_single_discovery, @@ -100,12 +101,12 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( - hass: HomeAssistant, entity_reg: EntityRegistry + hass: HomeAssistant, entity_reg: er.EntityRegistry ) -> None: """Test no migration happens if the original entity id still exists.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=MAC_ADDRESS) config_entry.add_to_hass(hass) - dimmer = _mocked_dimmer() + dimmer = _mocked_device(alias="My dimmer", modules=[Module.Light]) rollout_unique_id = MAC_ADDRESS.replace(":", "").upper() original_unique_id = tplink.legacy_device_id(dimmer) original_dimmer_entity_reg = entity_reg.async_get_or_create( @@ -129,7 +130,7 @@ async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( _patch_connect(device=dimmer), ): await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) migrated_dimmer_entity_reg = entity_reg.async_get_or_create( config_entry=config_entry, @@ -238,8 +239,8 @@ async def test_config_entry_device_config_invalid( @pytest.mark.parametrize( ("error_type", "entry_state", "reauth_flows"), [ - (tplink.AuthenticationException, ConfigEntryState.SETUP_ERROR, True), - (tplink.SmartDeviceException, ConfigEntryState.SETUP_RETRY, False), + (tplink.AuthenticationError, ConfigEntryState.SETUP_ERROR, True), + (tplink.KasaException, ConfigEntryState.SETUP_RETRY, False), ], ids=["invalid-auth", "unknown-error"], ) @@ -275,15 +276,15 @@ async def test_plug_auth_fails(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) config_entry.add_to_hass(hass) - plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_connect(device=plug): + device = _mocked_device(alias="my_plug", features=["state"]) + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() entity_id = "switch.my_plug" state = hass.states.get(entity_id) assert state.state == STATE_ON - plug.update = AsyncMock(side_effect=AuthenticationException) + device.update = AsyncMock(side_effect=AuthenticationError) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -298,3 +299,166 @@ async def test_plug_auth_fails(hass: HomeAssistant) -> None: ) == 1 ) + + +async def test_update_attrs_fails_in_init( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a smart plug auth failure.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + config_entry.add_to_hass(hass) + light = _mocked_device(modules=[Module.Light], alias="my_light") + light_module = light.modules[Module.Light] + p = PropertyMock(side_effect=KasaException) + type(light_module).color_temp = p + light.__str__ = lambda _: "MockLight" + with _patch_discovery(device=light), _patch_connect(device=light): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_light" + entity = entity_registry.async_get(entity_id) + assert entity + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + assert "Unable to read data for MockLight None:" in caplog.text + + +async def test_update_attrs_fails_on_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a smart plug auth failure.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + config_entry.add_to_hass(hass) + light = _mocked_device(modules=[Module.Light], alias="my_light") + light_module = light.modules[Module.Light] + + with _patch_discovery(device=light), _patch_connect(device=light): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_light" + entity = entity_registry.async_get(entity_id) + assert entity + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + p = PropertyMock(side_effect=KasaException) + type(light_module).color_temp = p + light.__str__ = lambda _: "MockLight" + freezer.tick(5) + async_fire_time_changed(hass) + entity = entity_registry.async_get(entity_id) + assert entity + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + assert f"Unable to read data for MockLight {entity_id}:" in caplog.text + # Check only logs once + caplog.clear() + freezer.tick(5) + async_fire_time_changed(hass) + entity = entity_registry.async_get(entity_id) + assert entity + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + assert f"Unable to read data for MockLight {entity_id}:" not in caplog.text + + +async def test_feature_no_category( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a strip unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + dev = _mocked_device( + alias="my_plug", + features=["led"], + ) + dev.features["led"].category = Feature.Category.Unset + with _patch_discovery(device=dev), _patch_connect(device=dev): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug_led" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.entity_category == EntityCategory.DIAGNOSTIC + assert "Unhandled category Category.Unset, fallback to DIAGNOSTIC" in caplog.text + + +@pytest.mark.parametrize( + ("identifier_base", "expected_message", "expected_count"), + [ + pytest.param("C0:06:C3:42:54:2B", "Replaced", 1, id="success"), + pytest.param("123456789", "Unable to replace", 3, id="failure"), + ], +) +async def test_unlink_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + identifier_base, + expected_message, + expected_count, +) -> None: + """Test for unlinking child device ids.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_LEGACY}, + entry_id="123456", + unique_id="any", + version=1, + minor_version=2, + ) + entry.add_to_hass(hass) + + # Setup initial device registry, with linkages + mac = "C0:06:C3:42:54:2B" + identifiers = [ + (DOMAIN, identifier_base), + (DOMAIN, f"{identifier_base}_0001"), + (DOMAIN, f"{identifier_base}_0002"), + ] + device_registry.async_get_or_create( + config_entry_id="123456", + connections={ + (dr.CONNECTION_NETWORK_MAC, mac.lower()), + }, + identifiers=set(identifiers), + model="hs300", + name="dummy", + ) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + + assert device_entries[0].connections == { + (dr.CONNECTION_NETWORK_MAC, mac.lower()), + } + assert device_entries[0].identifiers == set(identifiers) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + + assert device_entries[0].connections == {(dr.CONNECTION_NETWORK_MAC, mac.lower())} + # If expected count is 1 will be the first identifier only + expected_identifiers = identifiers[:expected_count] + assert device_entries[0].identifiers == set(expected_identifiers) + assert entry.version == 1 + assert entry.minor_version == 3 + + msg = f"{expected_message} identifiers for device dummy (hs300): {set(identifiers)}" + assert msg in caplog.text diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 9f352e7ffc4..c2f40f47e3d 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -5,7 +5,16 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import MagicMock, PropertyMock -from kasa import AuthenticationException, SmartDeviceException, TimeoutException +from kasa import ( + AuthenticationError, + DeviceType, + KasaException, + LightState, + Module, + TimeoutError, +) +from kasa.interfaces import LightEffect +from kasa.iot import IotDevice import pytest from homeassistant.components import tplink @@ -23,6 +32,7 @@ from homeassistant.components.light import ( ATTR_TRANSITION, ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, + EFFECT_OFF, ) from homeassistant.components.tplink.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH @@ -34,9 +44,9 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import ( + DEVICE_ID, MAC_ADDRESS, - _mocked_bulb, - _mocked_smart_light_strip, + _mocked_device, _patch_connect, _patch_discovery, _patch_single_discovery, @@ -45,37 +55,77 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.parametrize( + ("device_type"), + [ + pytest.param(DeviceType.Dimmer, id="Dimmer"), + pytest.param(DeviceType.Bulb, id="Bulb"), + pytest.param(DeviceType.LightStrip, id="LightStrip"), + pytest.param(DeviceType.WallSwitch, id="WallSwitch"), + ], +) async def test_light_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, device_type ) -> None: """Test a light unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.color_temp = None - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + light = _mocked_device(modules=[Module.Light], alias="my_light") + light.device_type = device_type + with _patch_discovery(device=light), _patch_connect(device=light): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" - assert entity_registry.async_get(entity_id).unique_id == "AABBCCDDEEFF" + entity_id = "light.my_light" + assert ( + entity_registry.async_get(entity_id).unique_id + == MAC_ADDRESS.replace(":", "").upper() + ) + + +async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + light = _mocked_device( + modules=[Module.Light], + alias="my_light", + spec=IotDevice, + device_id="aa:bb:cc:dd:ee:ff", + ) + light.device_type = DeviceType.Dimmer + + with _patch_discovery(device=light), _patch_connect(device=light): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_light" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" @pytest.mark.parametrize( - ("bulb", "transition"), [(_mocked_bulb(), 2.0), (_mocked_smart_light_strip(), None)] + ("device", "transition"), + [ + (_mocked_device(modules=[Module.Light]), 2.0), + (_mocked_device(modules=[Module.Light, Module.LightEffect]), None), + ], ) async def test_color_light( - hass: HomeAssistant, bulb: MagicMock, transition: float | None + hass: HomeAssistant, device: MagicMock, transition: float | None ) -> None: """Test a color light and that all transitions are correctly passed.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb.color_temp = None - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + light = device.modules[Module.Light] + light.color_temp = None + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -101,11 +151,16 @@ async def test_color_light( await hass.services.async_call( LIGHT_DOMAIN, "turn_off", BASE_PAYLOAD, blocking=True ) - bulb.turn_off.assert_called_once_with(transition=KASA_TRANSITION_VALUE) + light.set_state.assert_called_once_with( + LightState(light_on=False, transition=KASA_TRANSITION_VALUE) + ) + light.set_state.reset_mock() await hass.services.async_call(LIGHT_DOMAIN, "turn_on", BASE_PAYLOAD, blocking=True) - bulb.turn_on.assert_called_once_with(transition=KASA_TRANSITION_VALUE) - bulb.turn_on.reset_mock() + light.set_state.assert_called_once_with( + LightState(light_on=True, transition=KASA_TRANSITION_VALUE) + ) + light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -113,8 +168,8 @@ async def test_color_light( {**BASE_PAYLOAD, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.set_brightness.assert_called_with(39, transition=KASA_TRANSITION_VALUE) - bulb.set_brightness.reset_mock() + light.set_brightness.assert_called_with(39, transition=KASA_TRANSITION_VALUE) + light.set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -122,10 +177,10 @@ async def test_color_light( {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) - bulb.set_color_temp.assert_called_with( + light.set_color_temp.assert_called_with( 6666, brightness=None, transition=KASA_TRANSITION_VALUE ) - bulb.set_color_temp.reset_mock() + light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -133,10 +188,10 @@ async def test_color_light( {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) - bulb.set_color_temp.assert_called_with( + light.set_color_temp.assert_called_with( 6666, brightness=None, transition=KASA_TRANSITION_VALUE ) - bulb.set_color_temp.reset_mock() + light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -144,8 +199,8 @@ async def test_color_light( {**BASE_PAYLOAD, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) - bulb.set_hsv.assert_called_with(10, 30, None, transition=KASA_TRANSITION_VALUE) - bulb.set_hsv.reset_mock() + light.set_hsv.assert_called_with(10, 30, None, transition=KASA_TRANSITION_VALUE) + light.set_hsv.reset_mock() async def test_color_light_no_temp(hass: HomeAssistant) -> None: @@ -154,14 +209,15 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.is_variable_color_temp = False - type(bulb).color_temp = PropertyMock(side_effect=Exception) - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] + light.is_variable_color_temp = False + type(light).color_temp = PropertyMock(side_effect=Exception) + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == "on" @@ -176,13 +232,14 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_off.assert_called_once() + light.set_state.assert_called_once() + light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_on.assert_called_once() - bulb.turn_on.reset_mock() + light.set_state.assert_called_once() + light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -190,8 +247,8 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.set_brightness.assert_called_with(39, transition=None) - bulb.set_brightness.reset_mock() + light.set_brightness.assert_called_with(39, transition=None) + light.set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -199,12 +256,16 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) - bulb.set_hsv.assert_called_with(10, 30, None, transition=None) - bulb.set_hsv.reset_mock() + light.set_hsv.assert_called_with(10, 30, None, transition=None) + light.set_hsv.reset_mock() @pytest.mark.parametrize( - ("bulb", "is_color"), [(_mocked_bulb(), True), (_mocked_smart_light_strip(), False)] + ("bulb", "is_color"), + [ + (_mocked_device(modules=[Module.Light], alias="my_light"), True), + (_mocked_device(modules=[Module.Light], alias="my_light"), False), + ], ) async def test_color_temp_light( hass: HomeAssistant, bulb: MagicMock, is_color: bool @@ -214,22 +275,24 @@ async def test_color_temp_light( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb.is_color = is_color - bulb.color_temp = 4000 - bulb.is_variable_color_temp = True + device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] + light.is_color = is_color + light.color_temp = 4000 + light.is_variable_color_temp = True - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == "on" attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" - if bulb.is_color: + if light.is_color: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] else: assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] @@ -240,13 +303,14 @@ async def test_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_off.assert_called_once() + light.set_state.assert_called_once() + light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_on.assert_called_once() - bulb.turn_on.reset_mock() + light.set_state.assert_called_once() + light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -254,8 +318,8 @@ async def test_color_temp_light( {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.set_brightness.assert_called_with(39, transition=None) - bulb.set_brightness.reset_mock() + light.set_brightness.assert_called_with(39, transition=None) + light.set_brightness.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -263,8 +327,8 @@ async def test_color_temp_light( {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) - bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) - bulb.set_color_temp.reset_mock() + light.set_color_temp.assert_called_with(6666, brightness=None, transition=None) + light.set_color_temp.reset_mock() # Verify color temp is clamped to the valid range await hass.services.async_call( @@ -273,8 +337,8 @@ async def test_color_temp_light( {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, blocking=True, ) - bulb.set_color_temp.assert_called_with(9000, brightness=None, transition=None) - bulb.set_color_temp.reset_mock() + light.set_color_temp.assert_called_with(9000, brightness=None, transition=None) + light.set_color_temp.reset_mock() # Verify color temp is clamped to the valid range await hass.services.async_call( @@ -283,8 +347,8 @@ async def test_color_temp_light( {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, blocking=True, ) - bulb.set_color_temp.assert_called_with(4000, brightness=None, transition=None) - bulb.set_color_temp.reset_mock() + light.set_color_temp.assert_called_with(4000, brightness=None, transition=None) + light.set_color_temp.reset_mock() async def test_brightness_only_light(hass: HomeAssistant) -> None: @@ -293,15 +357,16 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.is_color = False - bulb.is_variable_color_temp = False + device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] + light.is_color = False + light.is_variable_color_temp = False - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == "on" @@ -313,13 +378,14 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_off.assert_called_once() + light.set_state.assert_called_once() + light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_on.assert_called_once() - bulb.turn_on.reset_mock() + light.set_state.assert_called_once() + light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -327,8 +393,8 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - bulb.set_brightness.assert_called_with(39, transition=None) - bulb.set_brightness.reset_mock() + light.set_brightness.assert_called_with(39, transition=None) + light.set_brightness.reset_mock() async def test_on_off_light(hass: HomeAssistant) -> None: @@ -337,16 +403,17 @@ async def test_on_off_light(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.is_color = False - bulb.is_variable_color_temp = False - bulb.is_dimmable = False + device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] + light.is_color = False + light.is_variable_color_temp = False + light.is_dimmable = False - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == "on" @@ -356,13 +423,14 @@ async def test_on_off_light(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_off.assert_called_once() + light.set_state.assert_called_once() + light.set_state.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_on.assert_called_once() - bulb.turn_on.reset_mock() + light.set_state.assert_called_once() + light.set_state.reset_mock() async def test_off_at_start_light(hass: HomeAssistant) -> None: @@ -371,17 +439,18 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.is_color = False - bulb.is_variable_color_temp = False - bulb.is_dimmable = False - bulb.is_on = False - - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] + light.is_color = False + light.is_variable_color_temp = False + light.is_dimmable = False + light.state = LightState(light_on=False) + + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == "off" @@ -395,15 +464,16 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.is_dimmer = True - bulb.is_on = False + device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] + device.device_type = DeviceType.Dimmer + light.state = LightState(light_on=False) - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == "off" @@ -411,8 +481,17 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - bulb.turn_on.assert_called_once_with(transition=1) - bulb.turn_on.reset_mock() + light.set_state.assert_called_once_with( + LightState( + light_on=True, + brightness=None, + hue=None, + saturation=None, + color_temp=None, + transition=1, + ) + ) + light.set_state.reset_mock() async def test_smart_strip_effects(hass: HomeAssistant) -> None: @@ -421,22 +500,26 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - strip = _mocked_smart_light_strip() + device = _mocked_device( + modules=[Module.Light, Module.LightEffect], alias="my_light" + ) + light = device.modules[Module.Light] + light_effect = device.modules[Module.LightEffect] with ( - _patch_discovery(device=strip), - _patch_single_discovery(device=strip), - _patch_connect(device=strip), + _patch_discovery(device=device), + _patch_single_discovery(device=device), + _patch_connect(device=device), ): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_EFFECT] == "Effect1" - assert state.attributes[ATTR_EFFECT_LIST] == ["Effect1", "Effect2"] + assert state.attributes[ATTR_EFFECT_LIST] == ["Off", "Effect1", "Effect2"] # Ensure setting color temp when an effect # is in progress calls set_hsv to clear the effect @@ -446,10 +529,10 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) - strip.set_hsv.assert_called_once_with(0, 0, None) - strip.set_color_temp.assert_called_once_with(4000, brightness=None, transition=None) - strip.set_hsv.reset_mock() - strip.set_color_temp.reset_mock() + light.set_hsv.assert_called_once_with(0, 0, None) + light.set_color_temp.assert_called_once_with(4000, brightness=None, transition=None) + light.set_hsv.reset_mock() + light.set_color_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -457,21 +540,20 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect2"}, blocking=True, ) - strip.set_effect.assert_called_once_with( + light_effect.set_effect.assert_called_once_with( "Effect2", brightness=None, transition=None ) - strip.set_effect.reset_mock() + light_effect.set_effect.reset_mock() - strip.effect = {"name": "Effect1", "enable": 0, "custom": 0} + light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_EFFECT] is None + assert state.attributes[ATTR_EFFECT] == EFFECT_OFF - strip.is_off = True - strip.is_on = False + light.state = LightState(light_on=False) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) await hass.async_block_till_done() @@ -485,12 +567,11 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - strip.turn_on.assert_called_once() - strip.turn_on.reset_mock() + light.set_state.assert_called_once() + light.set_state.reset_mock() - strip.is_off = False - strip.is_on = True - strip.effect_list = None + light.state = LightState(light_on=True) + light_effect.effect_list = None async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -505,13 +586,17 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - strip = _mocked_smart_light_strip() + device = _mocked_device( + modules=[Module.Light, Module.LightEffect], alias="my_light" + ) + light = device.modules[Module.Light] + light_effect = device.modules[Module.LightEffect] - with _patch_discovery(device=strip), _patch_connect(device=strip): + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -526,7 +611,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: }, blocking=True, ) - strip.set_custom_effect.assert_called_once_with( + light_effect.set_custom_effect.assert_called_once_with( { "custom": 1, "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", @@ -543,7 +628,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: "backgrounds": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], } ) - strip.set_custom_effect.reset_mock() + light_effect.set_custom_effect.reset_mock() await hass.services.async_call( DOMAIN, @@ -555,7 +640,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: }, blocking=True, ) - strip.set_custom_effect.assert_called_once_with( + light_effect.set_custom_effect.assert_called_once_with( { "custom": 1, "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", @@ -571,9 +656,9 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: "random_seed": 600, } ) - strip.set_custom_effect.reset_mock() + light_effect.set_custom_effect.reset_mock() - strip.effect = { + light_effect.effect = { "custom": 1, "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", "brightness": 100, @@ -586,15 +671,8 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ON - strip.is_off = True - strip.is_on = False - strip.effect = { - "custom": 1, - "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", - "brightness": 100, - "name": "Custom", - "enable": 0, - } + light.state = LightState(light_on=False) + light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) await hass.async_block_till_done() @@ -608,8 +686,8 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - strip.turn_on.assert_called_once() - strip.turn_on.reset_mock() + light.set_state.assert_called_once() + light.set_state.reset_mock() await hass.services.async_call( DOMAIN, @@ -631,7 +709,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - strip.set_custom_effect.assert_called_once_with( + light_effect.set_custom_effect.assert_called_once_with( { "custom": 1, "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", @@ -653,7 +731,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: "transition_range": [2000, 3000], } ) - strip.set_custom_effect.reset_mock() + light_effect.set_custom_effect.reset_mock() async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> None: @@ -662,19 +740,17 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - strip = _mocked_smart_light_strip() - strip.effect = { - "custom": 1, - "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", - "brightness": 100, - "name": "Custom", - "enable": 0, - } - with _patch_discovery(device=strip), _patch_connect(device=strip): + device = _mocked_device( + modules=[Module.Light, Module.LightEffect], alias="my_light" + ) + light = device.modules[Module.Light] + light_effect = device.modules[Module.LightEffect] + light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -685,8 +761,8 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - strip.turn_on.assert_called_once() - strip.turn_on.reset_mock() + light.set_state.assert_called_once() + light.set_state.reset_mock() async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: @@ -695,13 +771,16 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - strip = _mocked_smart_light_strip() + device = _mocked_device( + modules=[Module.Light, Module.LightEffect], alias="my_light" + ) + light_effect = device.modules[Module.LightEffect] - with _patch_discovery(device=strip), _patch_connect(device=strip): + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -715,7 +794,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: }, blocking=True, ) - strip.set_custom_effect.assert_called_once_with( + light_effect.set_custom_effect.assert_called_once_with( { "custom": 1, "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", @@ -733,24 +812,24 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: "direction": 4, } ) - strip.set_custom_effect.reset_mock() + light_effect.set_custom_effect.reset_mock() @pytest.mark.parametrize( ("exception_type", "msg", "reauth_expected"), [ ( - AuthenticationException, + AuthenticationError, "Device authentication error async_turn_on: test error", True, ), ( - TimeoutException, + TimeoutError, "Timeout communicating with the device async_turn_on: test error", False, ), ( - SmartDeviceException, + KasaException, "Unable to communicate with the device async_turn_on: test error", False, ), @@ -768,14 +847,15 @@ async def test_light_errors_when_turned_on( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.turn_on.side_effect = exception_type(msg) + device = _mocked_device(modules=[Module.Light], alias="my_light") + light = device.modules[Module.Light] + light.set_state.side_effect = exception_type(msg) - with _patch_discovery(device=bulb), _patch_connect(device=bulb): + with _patch_discovery(device=device), _patch_connect(device=device): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - entity_id = "light.my_bulb" + entity_id = "light.my_light" assert not any( already_migrated_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH}) @@ -786,7 +866,7 @@ async def test_light_errors_when_turned_on( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() - assert bulb.turn_on.call_count == 1 + assert light.set_state.call_count == 1 assert ( any( flow @@ -797,3 +877,42 @@ async def test_light_errors_when_turned_on( ) == reauth_expected ) + + +async def test_light_child( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test child lights are added to parent device with the right ids.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + + child_light_1 = _mocked_device( + modules=[Module.Light], alias="my_light_0", device_id=f"{DEVICE_ID}00" + ) + child_light_2 = _mocked_device( + modules=[Module.Light], alias="my_light_1", device_id=f"{DEVICE_ID}01" + ) + parent_device = _mocked_device( + device_id=DEVICE_ID, + alias="my_device", + children=[child_light_1, child_light_2], + modules=[Module.Light], + ) + + with _patch_discovery(device=parent_device), _patch_connect(device=parent_device): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_device" + entity = entity_registry.async_get(entity_id) + assert entity + + for light_id in range(2): + child_entity_id = f"light.my_device_my_light_{light_id}" + child_entity = entity_registry.async_get(child_entity_id) + assert child_entity + assert child_entity.unique_id == f"{DEVICE_ID}0{light_id}" + assert child_entity.device_id == entity.device_id diff --git a/tests/components/tplink/test_number.py b/tests/components/tplink/test_number.py new file mode 100644 index 00000000000..865ce27ffc0 --- /dev/null +++ b/tests/components/tplink/test_number.py @@ -0,0 +1,163 @@ +"""Tests for tplink number platform.""" + +from kasa import Feature +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import tplink +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.entity import EXCLUDED_FEATURES +from homeassistant.components.tplink.number import NUMBER_DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + DEVICE_ID, + MAC_ADDRESS, + _mocked_device, + _mocked_feature, + _mocked_strip_children, + _patch_connect, + _patch_discovery, + setup_platform_for_device, + snapshot_platform, +) + +from tests.common import MockConfigEntry + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a sensor unique ids.""" + features = {description.key for description in NUMBER_DESCRIPTIONS} + features.update(EXCLUDED_FEATURES) + device = _mocked_device(alias="my_device", features=features) + + await setup_platform_for_device(hass, mock_config_entry, Platform.NUMBER, device) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + for excluded in EXCLUDED_FEATURES: + assert hass.states.get(f"sensor.my_device_{excluded}") is None + + +async def test_number(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: + """Test a sensor unique ids.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + new_feature = _mocked_feature( + "temperature_offset", + value=10, + name="Temperature offset", + type_=Feature.Type.Number, + category=Feature.Category.Config, + minimum_value=1, + maximum_value=100, + ) + plug = _mocked_device(alias="my_plug", features=[new_feature]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "number.my_plug_temperature_offset" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_temperature_offset" + + +async def test_number_children( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a sensor unique ids.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + new_feature = _mocked_feature( + "temperature_offset", + value=10, + name="Some number", + type_=Feature.Type.Number, + category=Feature.Category.Config, + minimum_value=1, + maximum_value=100, + ) + plug = _mocked_device( + alias="my_plug", + features=[new_feature], + children=_mocked_strip_children(features=[new_feature]), + ) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "number.my_plug_temperature_offset" + entity = entity_registry.async_get(entity_id) + assert entity + device = device_registry.async_get(entity.device_id) + + for plug_id in range(2): + child_entity_id = f"number.my_plug_plug{plug_id}_temperature_offset" + child_entity = entity_registry.async_get(child_entity_id) + assert child_entity + assert child_entity.unique_id == f"PLUG{plug_id}DEVICEID_temperature_offset" + assert child_entity.device_id != entity.device_id + child_device = device_registry.async_get(child_entity.device_id) + assert child_device + assert child_device.via_device_id == device.id + + +async def test_number_set( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a number entity limits and setting values.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + new_feature = _mocked_feature( + "temperature_offset", + value=10, + name="Some number", + type_=Feature.Type.Number, + category=Feature.Category.Config, + minimum_value=1, + maximum_value=200, + ) + plug = _mocked_device(alias="my_plug", features=[new_feature]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "number.my_plug_temperature_offset" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_temperature_offset" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "10" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + new_feature.set_value.assert_called_with(50) diff --git a/tests/components/tplink/test_select.py b/tests/components/tplink/test_select.py new file mode 100644 index 00000000000..6c49185d91c --- /dev/null +++ b/tests/components/tplink/test_select.py @@ -0,0 +1,158 @@ +"""Tests for tplink select platform.""" + +from kasa import Feature +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import tplink +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.entity import EXCLUDED_FEATURES +from homeassistant.components.tplink.select import SELECT_DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + DEVICE_ID, + MAC_ADDRESS, + _mocked_device, + _mocked_feature, + _mocked_strip_children, + _patch_connect, + _patch_discovery, + setup_platform_for_device, + snapshot_platform, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mocked_feature_select() -> Feature: + """Return mocked tplink binary sensor feature.""" + return _mocked_feature( + "light_preset", + value="First choice", + name="light_preset", + choices=["First choice", "Second choice"], + type_=Feature.Type.Choice, + category=Feature.Category.Config, + ) + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a sensor unique ids.""" + features = {description.key for description in SELECT_DESCRIPTIONS} + features.update(EXCLUDED_FEATURES) + device = _mocked_device(alias="my_device", features=features) + + await setup_platform_for_device(hass, mock_config_entry, Platform.SELECT, device) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + for excluded in EXCLUDED_FEATURES: + assert hass.states.get(f"sensor.my_device_{excluded}") is None + + +async def test_select( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mocked_feature_select: Feature, +) -> None: + """Test a sensor unique ids.""" + mocked_feature = mocked_feature_select + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + + plug = _mocked_device(alias="my_plug", features=[mocked_feature]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + # The entity_id is based on standard name from core. + entity_id = "select.my_plug_light_preset" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_{mocked_feature.id}" + + +async def test_select_children( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mocked_feature_select: Feature, +) -> None: + """Test a sensor unique ids.""" + mocked_feature = mocked_feature_select + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_device( + alias="my_plug", + features=[mocked_feature], + children=_mocked_strip_children(features=[mocked_feature]), + ) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "select.my_plug_light_preset" + entity = entity_registry.async_get(entity_id) + assert entity + device = device_registry.async_get(entity.device_id) + + for plug_id in range(2): + child_entity_id = f"select.my_plug_plug{plug_id}_light_preset" + child_entity = entity_registry.async_get(child_entity_id) + assert child_entity + assert child_entity.unique_id == f"PLUG{plug_id}DEVICEID_{mocked_feature.id}" + assert child_entity.device_id != entity.device_id + child_device = device_registry.async_get(child_entity.device_id) + assert child_device + assert child_device.via_device_id == device.id + + +async def test_select_select( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mocked_feature_select: Feature, +) -> None: + """Test a select setting values.""" + mocked_feature = mocked_feature_select + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_device(alias="my_plug", features=[mocked_feature]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "select.my_plug_light_preset" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_light_preset" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Second choice"}, + blocking=True, + ) + mocked_feature.set_value.assert_called_with("Second choice") diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index 43884083483..dda43c52430 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -1,35 +1,71 @@ """Tests for light platform.""" -from unittest.mock import Mock +from kasa import Device, Feature, Module +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import tplink from homeassistant.components.tplink.const import DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.components.tplink.entity import EXCLUDED_FEATURES +from homeassistant.components.tplink.sensor import SENSOR_DESCRIPTIONS +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from . import MAC_ADDRESS, _mocked_bulb, _mocked_plug, _patch_connect, _patch_discovery +from . import ( + DEVICE_ID, + MAC_ADDRESS, + _mocked_device, + _mocked_energy_features, + _mocked_feature, + _mocked_strip_children, + _patch_connect, + _patch_discovery, + setup_platform_for_device, + snapshot_platform, +) from tests.common import MockConfigEntry +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a sensor unique ids.""" + features = {description.key for description in SENSOR_DESCRIPTIONS} + features.update(EXCLUDED_FEATURES) + device = _mocked_device(alias="my_device", features=features) + + await setup_platform_for_device(hass, mock_config_entry, Platform.SENSOR, device) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + for excluded in EXCLUDED_FEATURES: + assert hass.states.get(f"sensor.my_device_{excluded}") is None + + async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: """Test a light with an emeter.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.color_temp = None - bulb.has_emeter = True - bulb.emeter_realtime = Mock( + emeter_features = _mocked_energy_features( power=None, total=None, voltage=None, current=5, + today=5000.0036, + ) + bulb = _mocked_device( + alias="my_bulb", modules=[Module.Light], features=["state", *emeter_features] ) - bulb.emeter_today = 5000.0036 with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -60,16 +96,13 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_plug() - plug.color_temp = None - plug.has_emeter = True - plug.emeter_realtime = Mock( + emeter_features = _mocked_energy_features( power=100.06, total=30.0049, voltage=121.19, current=5.035, ) - plug.emeter_today = None + plug = _mocked_device(alias="my_plug", features=["state", *emeter_features]) with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -95,8 +128,7 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - bulb = _mocked_bulb() - bulb.color_temp = None + bulb = _mocked_device(alias="my_bulb", modules=[Module.Light]) bulb.has_emeter = False with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) @@ -126,26 +158,175 @@ async def test_sensor_unique_id( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_plug() - plug.color_temp = None - plug.has_emeter = True - plug.emeter_realtime = Mock( + emeter_features = _mocked_energy_features( power=100, total=30, voltage=121, current=5, + today=None, ) - plug.emeter_today = None + plug = _mocked_device(alias="my_plug", features=emeter_features) with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() expected = { - "sensor.my_plug_current_consumption": "aa:bb:cc:dd:ee:ff_current_power_w", - "sensor.my_plug_total_consumption": "aa:bb:cc:dd:ee:ff_total_energy_kwh", - "sensor.my_plug_today_s_consumption": "aa:bb:cc:dd:ee:ff_today_energy_kwh", - "sensor.my_plug_voltage": "aa:bb:cc:dd:ee:ff_voltage", - "sensor.my_plug_current": "aa:bb:cc:dd:ee:ff_current_a", + "sensor.my_plug_current_consumption": f"{DEVICE_ID}_current_power_w", + "sensor.my_plug_total_consumption": f"{DEVICE_ID}_total_energy_kwh", + "sensor.my_plug_today_s_consumption": f"{DEVICE_ID}_today_energy_kwh", + "sensor.my_plug_voltage": f"{DEVICE_ID}_voltage", + "sensor.my_plug_current": f"{DEVICE_ID}_current_a", } for sensor_entity_id, value in expected.items(): assert entity_registry.async_get(sensor_entity_id).unique_id == value + + +async def test_undefined_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a message is logged when discovering a feature without a description.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + new_feature = _mocked_feature( + "consumption_this_fortnight", + value=5.2, + name="Consumption for fortnight", + type_=Feature.Type.Sensor, + category=Feature.Category.Primary, + unit="A", + precision_hint=2, + ) + plug = _mocked_device(alias="my_plug", features=[new_feature]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + msg = ( + "Device feature: Consumption for fortnight (consumption_this_fortnight) " + "needs an entity description defined in HA" + ) + assert msg in caplog.text + + +async def test_sensor_children_on_parent( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a WallSwitch sensor entities are added to parent.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + feature = _mocked_feature( + "consumption_this_month", + value=5.2, + # integration should ignore name and use the value from strings.json: + # This month's consumption + name="Consumption for month", + type_=Feature.Type.Sensor, + category=Feature.Category.Primary, + unit="A", + precision_hint=2, + ) + plug = _mocked_device( + alias="my_plug", + features=[feature], + children=_mocked_strip_children(features=[feature]), + device_type=Device.Type.WallSwitch, + ) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "sensor.my_plug_this_month_s_consumption" + entity = entity_registry.async_get(entity_id) + assert entity + device = device_registry.async_get(entity.device_id) + + for plug_id in range(2): + child_entity_id = f"sensor.my_plug_plug{plug_id}_this_month_s_consumption" + child_entity = entity_registry.async_get(child_entity_id) + assert child_entity + assert child_entity.unique_id == f"PLUG{plug_id}DEVICEID_consumption_this_month" + child_device = device_registry.async_get(child_entity.device_id) + assert child_device + + assert child_entity.device_id == entity.device_id + assert child_device.connections == device.connections + + +async def test_sensor_children_on_child( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test strip sensors are on child device.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + feature = _mocked_feature( + "consumption_this_month", + value=5.2, + # integration should ignore name and use the value from strings.json: + # This month's consumption + name="Consumption for month", + type_=Feature.Type.Sensor, + category=Feature.Category.Primary, + unit="A", + precision_hint=2, + ) + plug = _mocked_device( + alias="my_plug", + features=[feature], + children=_mocked_strip_children(features=[feature]), + device_type=Device.Type.Strip, + ) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "sensor.my_plug_this_month_s_consumption" + entity = entity_registry.async_get(entity_id) + assert entity + device = device_registry.async_get(entity.device_id) + + for plug_id in range(2): + child_entity_id = f"sensor.my_plug_plug{plug_id}_this_month_s_consumption" + child_entity = entity_registry.async_get(child_entity_id) + assert child_entity + assert child_entity.unique_id == f"PLUG{plug_id}DEVICEID_consumption_this_month" + child_device = device_registry.async_get(child_entity.device_id) + assert child_device + + assert child_entity.device_id != entity.device_id + assert child_device.via_device_id == device.id + + +@pytest.mark.skip +async def test_new_datetime_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a sensor unique ids.""" + # Skipped temporarily while datetime handling on hold. + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + plug = _mocked_device(alias="my_plug", features=["on_since"]) + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "sensor.my_plug_on_since" + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == f"{DEVICE_ID}_on_since" + state = hass.states.get(entity_id) + assert state + assert state.attributes["device_class"] == "timestamp" diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index 02913e0c37e..e9c8cc07b67 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -3,12 +3,16 @@ from datetime import timedelta from unittest.mock import AsyncMock -from kasa import AuthenticationException, SmartDeviceException, TimeoutException +from kasa import AuthenticationError, Device, KasaException, Module, TimeoutError +from kasa.iot import IotStrip import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import tplink from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.tplink.const import DOMAIN +from homeassistant.components.tplink.entity import EXCLUDED_FEATURES +from homeassistant.components.tplink.switch import SWITCH_DESCRIPTIONS from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ( ATTR_ENTITY_ID, @@ -16,32 +20,57 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from . import ( + DEVICE_ID, MAC_ADDRESS, - _mocked_dimmer, - _mocked_plug, - _mocked_strip, + _mocked_device, + _mocked_strip_children, _patch_connect, _patch_discovery, + setup_platform_for_device, + snapshot_platform, ) from tests.common import MockConfigEntry, async_fire_time_changed +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a sensor unique ids.""" + features = {description.key for description in SWITCH_DESCRIPTIONS} + features.update(EXCLUDED_FEATURES) + device = _mocked_device(alias="my_device", features=features) + + await setup_platform_for_device(hass, mock_config_entry, Platform.SWITCH, device) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + for excluded in EXCLUDED_FEATURES: + assert hass.states.get(f"sensor.my_device_{excluded}") is None + + async def test_plug(hass: HomeAssistant) -> None: """Test a smart plug.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_plug() + plug = _mocked_device(alias="my_plug", features=["state"]) + feat = plug.features["state"] with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -53,29 +82,42 @@ async def test_plug(hass: HomeAssistant) -> None: await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - plug.turn_off.assert_called_once() - plug.turn_off.reset_mock() + feat.set_value.assert_called_once() + feat.set_value.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - plug.turn_on.assert_called_once() - plug.turn_on.reset_mock() + feat.set_value.assert_called_once() + feat.set_value.reset_mock() @pytest.mark.parametrize( ("dev", "domain"), [ - (_mocked_plug(), "switch"), - (_mocked_strip(), "switch"), - (_mocked_dimmer(), "light"), + (_mocked_device(alias="my_plug", features=["state", "led"]), "switch"), + ( + _mocked_device( + alias="my_strip", + features=["state", "led"], + children=_mocked_strip_children(), + ), + "switch", + ), + ( + _mocked_device( + alias="my_light", modules=[Module.Light], features=["state", "led"] + ), + "light", + ), ], ) -async def test_led_switch(hass: HomeAssistant, dev, domain: str) -> None: +async def test_led_switch(hass: HomeAssistant, dev: Device, domain: str) -> None: """Test LED setting for plugs, strips and dimmers.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) + feat = dev.features["led"] already_migrated_config_entry.add_to_hass(hass) with _patch_discovery(device=dev), _patch_connect(device=dev): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) @@ -91,14 +133,14 @@ async def test_led_switch(hass: HomeAssistant, dev, domain: str) -> None: await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) - dev.set_led.assert_called_once_with(False) - dev.set_led.reset_mock() + feat.set_value.assert_called_once_with(False) + feat.set_value.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: led_entity_id}, blocking=True ) - dev.set_led.assert_called_once_with(True) - dev.set_led.reset_mock() + feat.set_value.assert_called_once_with(True) + feat.set_value.reset_mock() async def test_plug_unique_id( @@ -109,13 +151,13 @@ async def test_plug_unique_id( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_plug() + plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() entity_id = "switch.my_plug" - assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" + assert entity_registry.async_get(entity_id).unique_id == DEVICE_ID async def test_plug_update_fails(hass: HomeAssistant) -> None: @@ -124,7 +166,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_plug() + plug = _mocked_device(alias="my_plug", features=["state", "led"]) with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -132,7 +174,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: entity_id = "switch.my_plug" state = hass.states.get(entity_id) assert state.state == STATE_ON - plug.update = AsyncMock(side_effect=SmartDeviceException) + plug.update = AsyncMock(side_effect=KasaException) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -146,15 +188,18 @@ async def test_strip(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - strip = _mocked_strip() + strip = _mocked_device( + alias="my_strip", + children=_mocked_strip_children(features=["state"]), + features=["state", "led"], + spec=IotStrip, + ) + strip.children[0].features["state"].value = True + strip.children[1].features["state"].value = False with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() - # Verify we only create entities for the children - # since this is what the previous version did - assert hass.states.get("switch.my_strip") is None - entity_id = "switch.my_strip_plug0" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -162,14 +207,15 @@ async def test_strip(hass: HomeAssistant) -> None: await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - strip.children[0].turn_off.assert_called_once() - strip.children[0].turn_off.reset_mock() + feat = strip.children[0].features["state"] + feat.set_value.assert_called_once() + feat.set_value.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - strip.children[0].turn_on.assert_called_once() - strip.children[0].turn_on.reset_mock() + feat.set_value.assert_called_once() + feat.set_value.reset_mock() entity_id = "switch.my_strip_plug1" state = hass.states.get(entity_id) @@ -178,14 +224,15 @@ async def test_strip(hass: HomeAssistant) -> None: await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - strip.children[1].turn_off.assert_called_once() - strip.children[1].turn_off.reset_mock() + feat = strip.children[1].features["state"] + feat.set_value.assert_called_once() + feat.set_value.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - strip.children[1].turn_on.assert_called_once() - strip.children[1].turn_on.reset_mock() + feat.set_value.assert_called_once() + feat.set_value.reset_mock() async def test_strip_unique_ids( @@ -196,7 +243,11 @@ async def test_strip_unique_ids( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - strip = _mocked_strip() + strip = _mocked_device( + alias="my_strip", + children=_mocked_strip_children(features=["state"]), + features=["state", "led"], + ) with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -208,21 +259,45 @@ async def test_strip_unique_ids( ) +async def test_strip_blank_alias( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a strip unique id.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + strip = _mocked_device( + alias="", + model="KS123", + children=_mocked_strip_children(features=["state", "led"], alias=""), + features=["state", "led"], + ) + with _patch_discovery(device=strip), _patch_connect(device=strip): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + for plug_id in range(2): + entity_id = f"switch.unnamed_ks123_stripsocket_{plug_id + 1}" + state = hass.states.get(entity_id) + assert state.name == f"Unnamed KS123 Stripsocket {plug_id + 1}" + + @pytest.mark.parametrize( ("exception_type", "msg", "reauth_expected"), [ ( - AuthenticationException, + AuthenticationError, "Device authentication error async_turn_on: test error", True, ), ( - TimeoutException, + TimeoutError, "Timeout communicating with the device async_turn_on: test error", False, ), ( - SmartDeviceException, + KasaException, "Unable to communicate with the device async_turn_on: test error", False, ), @@ -240,8 +315,9 @@ async def test_plug_errors_when_turned_on( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - plug = _mocked_plug() - plug.turn_on.side_effect = exception_type("test error") + plug = _mocked_device(alias="my_plug", features=["state", "led"]) + feat = plug.features["state"] + feat.set_value.side_effect = exception_type("test error") with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) @@ -258,7 +334,7 @@ async def test_plug_errors_when_turned_on( SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) await hass.async_block_till_done() - assert plug.turn_on.call_count == 1 + assert feat.set_value.call_count == 1 assert ( any( flow -- GitLab