From 4290a1fcb5675c618a71093222b232a39239f95f Mon Sep 17 00:00:00 2001
From: Teemu R <tpr@iki.fi>
Date: Tue, 25 Jun 2024 22:01:21 +0200
Subject: [PATCH] Upgrade tplink with new platforms, features and device
 support (#120060)

Co-authored-by: Teemu Rytilahti <tpr@iki.fi>
Co-authored-by: sdb9696 <steven.beth@gmail.com>
Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Teemu R. <tpr@iki.fi>
---
 homeassistant/components/tplink/README.md     |  34 +
 homeassistant/components/tplink/__init__.py   | 125 ++-
 .../components/tplink/binary_sensor.py        |  96 +++
 homeassistant/components/tplink/button.py     |  69 ++
 homeassistant/components/tplink/climate.py    | 140 ++++
 .../components/tplink/config_flow.py          |  56 +-
 homeassistant/components/tplink/const.py      |  22 +-
 .../components/tplink/coordinator.py          |   8 +-
 .../components/tplink/diagnostics.py          |  12 +-
 homeassistant/components/tplink/entity.py     | 375 ++++++++-
 homeassistant/components/tplink/fan.py        | 111 +++
 homeassistant/components/tplink/icons.json    |  99 +++
 homeassistant/components/tplink/light.py      | 190 +++--
 homeassistant/components/tplink/manifest.json |  30 +-
 homeassistant/components/tplink/number.py     | 108 +++
 homeassistant/components/tplink/select.py     |  95 +++
 homeassistant/components/tplink/sensor.py     | 229 +++--
 homeassistant/components/tplink/strings.json  | 140 +++-
 homeassistant/components/tplink/switch.py     | 182 ++--
 homeassistant/generated/dhcp.py               |  35 +
 requirements_all.txt                          |   2 +-
 requirements_test_all.txt                     |   2 +-
 tests/components/tplink/__init__.py           | 514 +++++++-----
 tests/components/tplink/conftest.py           |  12 +-
 .../components/tplink/fixtures/features.json  | 287 +++++++
 .../tplink/snapshots/test_binary_sensor.ambr  | 369 ++++++++
 .../tplink/snapshots/test_button.ambr         | 127 +++
 .../tplink/snapshots/test_climate.ambr        |  94 +++
 .../components/tplink/snapshots/test_fan.ambr | 194 +++++
 .../tplink/snapshots/test_number.ambr         | 255 ++++++
 .../tplink/snapshots/test_select.ambr         | 238 ++++++
 .../tplink/snapshots/test_sensor.ambr         | 790 ++++++++++++++++++
 .../tplink/snapshots/test_switch.ambr         | 311 +++++++
 tests/components/tplink/test_binary_sensor.py | 124 +++
 tests/components/tplink/test_button.py        | 153 ++++
 tests/components/tplink/test_climate.py       | 226 +++++
 tests/components/tplink/test_config_flow.py   |  54 +-
 tests/components/tplink/test_diagnostics.py   |  10 +-
 tests/components/tplink/test_fan.py           | 154 ++++
 tests/components/tplink/test_init.py          | 190 ++++-
 tests/components/tplink/test_light.py         | 429 ++++++----
 tests/components/tplink/test_number.py        | 163 ++++
 tests/components/tplink/test_select.py        | 158 ++++
 tests/components/tplink/test_sensor.py        | 233 +++++-
 tests/components/tplink/test_switch.py        | 160 +++-
 45 files changed, 6542 insertions(+), 863 deletions(-)
 create mode 100644 homeassistant/components/tplink/README.md
 create mode 100644 homeassistant/components/tplink/binary_sensor.py
 create mode 100644 homeassistant/components/tplink/button.py
 create mode 100644 homeassistant/components/tplink/climate.py
 create mode 100644 homeassistant/components/tplink/fan.py
 create mode 100644 homeassistant/components/tplink/number.py
 create mode 100644 homeassistant/components/tplink/select.py
 create mode 100644 tests/components/tplink/fixtures/features.json
 create mode 100644 tests/components/tplink/snapshots/test_binary_sensor.ambr
 create mode 100644 tests/components/tplink/snapshots/test_button.ambr
 create mode 100644 tests/components/tplink/snapshots/test_climate.ambr
 create mode 100644 tests/components/tplink/snapshots/test_fan.ambr
 create mode 100644 tests/components/tplink/snapshots/test_number.ambr
 create mode 100644 tests/components/tplink/snapshots/test_select.ambr
 create mode 100644 tests/components/tplink/snapshots/test_sensor.ambr
 create mode 100644 tests/components/tplink/snapshots/test_switch.ambr
 create mode 100644 tests/components/tplink/test_binary_sensor.py
 create mode 100644 tests/components/tplink/test_button.py
 create mode 100644 tests/components/tplink/test_climate.py
 create mode 100644 tests/components/tplink/test_fan.py
 create mode 100644 tests/components/tplink/test_number.py
 create mode 100644 tests/components/tplink/test_select.py

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