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>
This commit is contained in:
Teemu R 2024-06-25 22:01:21 +02:00 committed by GitHub
parent 9bc4361855
commit 4290a1fcb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 6528 additions and 849 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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"

View file

@ -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:

View file

@ -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,
}

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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"
}
}
},

View file

@ -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)

View file

@ -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"]
}

View file

@ -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

View file

@ -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

View file

@ -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
)
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
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_update_attrs()
super()._handle_coordinator_update()
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
)

View file

@ -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"
},
"total_consumption_child": {
"name": "{device_name} total consumption"
"on_since": {
"name": "On since"
},
"today_consumption_child": {
"name": "{device_name} today's consumption"
"ssid": {
"name": "SSID"
},
"current_child": {
"name": "{device_name} current"
"signal_level": {
"name": "Signal level"
},
"voltage_child": {
"name": "{device_name} voltage"
"current_firmware_version": {
"name": "Current firmware version"
},
"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"
}
}
},

View file

@ -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."""
class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity):
"""Representation of a feature-based TPLink switch."""
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."""
_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()
await self._feature.set_value(False)
@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()
@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

View file

@ -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*",

View file

@ -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

View file

@ -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

View file

@ -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
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
class MockedSmartLightStrip(SmartLightStrip):
"""Mock a SmartLightStrip."""
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.
def __new__(cls, *args, **kwargs):
"""Mock a SmartLightStrip that will pass an isinstance check."""
return MagicMock(spec=cls)
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_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_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_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_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_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_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() -> 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"
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.

View file

@ -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,

View file

@ -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"]
}
}

View file

@ -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,
})
# ---

View file

@ -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,
})
# ---

View file

@ -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,
})
# ---

View file

@ -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,
})
# ---

View file

@ -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',
})
# ---

View file

@ -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',
})
# ---

View file

@ -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',
})
# ---

View file

@ -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',
})
# ---

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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={}):

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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
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=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"
@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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"

View file

@ -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