From 54bb53899e4300b57847c59b8cd715e416912c3e Mon Sep 17 00:00:00 2001
From: steveredden <35814432+steveredden@users.noreply.github.com>
Date: Thu, 23 Jan 2025 03:22:41 -0600
Subject: [PATCH] Add support for doorbells and chimes (#1435)
Add support for `smart` chimes and `smartcam` doorbells that are not hub child devices.
Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com>
---
README.md | 5 +++--
SUPPORTED.md | 21 ++++++++++++---------
devtools/generate_supported.py | 4 +++-
kasa/device_factory.py | 6 +++++-
kasa/device_type.py | 2 ++
kasa/deviceconfig.py | 2 ++
kasa/smart/smartdevice.py | 2 ++
kasa/smartcam/modules/camera.py | 7 ++-----
kasa/smartcam/smartcamchild.py | 7 +++++++
kasa/smartcam/smartcamdevice.py | 20 +++++++++-----------
tests/device_fixtures.py | 13 +++++++++++++
tests/test_device.py | 16 +++-------------
tests/test_device_factory.py | 12 ++++++++++++
13 files changed, 75 insertions(+), 42 deletions(-)
diff --git a/README.md b/README.md
index 8c7ac09a..9761684f 100644
--- a/README.md
+++ b/README.md
@@ -201,10 +201,11 @@ The following devices have been tested and confirmed as working. If your device
- **Wall Switches**: S210, S220, S500D, S505, S505D
- **Bulbs**: L510B, L510E, L530E, L630
- **Light Strips**: L900-10, L900-5, L920-5, L930-5
-- **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, D230, TC65, TC70
+- **Cameras**: C100, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70
+- **Doorbells and chimes**: D230
+- **Vacuums**: RV20 Max Plus, RV30 Max
- **Hubs**: H100, H200
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
-- **Vacuums**: RV20 Max Plus, RV30 Max
[^1]: Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md
index 905f7ab3..01d2d63e 100644
--- a/SUPPORTED.md
+++ b/SUPPORTED.md
@@ -285,13 +285,23 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros
- Hardware: 1.0 (US) / Firmware: 1.2.8
- **C720**
- Hardware: 1.0 (US) / Firmware: 1.2.3
-- **D230**
- - Hardware: 1.20 (EU) / Firmware: 1.1.19
- **TC65**
- Hardware: 1.0 / Firmware: 1.3.9
- **TC70**
- Hardware: 3.0 / Firmware: 1.3.11
+### Doorbells and chimes
+
+- **D230**
+ - Hardware: 1.20 (EU) / Firmware: 1.1.19
+
+### Vacuums
+
+- **RV20 Max Plus**
+ - Hardware: 1.0 (EU) / Firmware: 1.0.7
+- **RV30 Max**
+ - Hardware: 1.0 (US) / Firmware: 1.2.0
+
### Hubs
- **H100**
@@ -326,13 +336,6 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros
- Hardware: 1.0 (EU) / Firmware: 1.7.0
- Hardware: 1.0 (US) / Firmware: 1.8.0
-### Vacuums
-
-- **RV20 Max Plus**
- - Hardware: 1.0 (EU) / Firmware: 1.0.7
-- **RV30 Max**
- - Hardware: 1.0 (US) / Firmware: 1.2.0
-
[^1]: Model requires authentication
diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py
index 8aba9b21..669a2de2 100755
--- a/devtools/generate_supported.py
+++ b/devtools/generate_supported.py
@@ -36,10 +36,12 @@ DEVICE_TYPE_TO_PRODUCT_GROUP = {
DeviceType.Bulb: "Bulbs",
DeviceType.LightStrip: "Light Strips",
DeviceType.Camera: "Cameras",
+ DeviceType.Doorbell: "Doorbells and chimes",
+ DeviceType.Chime: "Doorbells and chimes",
+ DeviceType.Vacuum: "Vacuums",
DeviceType.Hub: "Hubs",
DeviceType.Sensor: "Hub-Connected Devices",
DeviceType.Thermostat: "Hub-Connected Devices",
- DeviceType.Vacuum: "Vacuums",
}
diff --git a/kasa/device_factory.py b/kasa/device_factory.py
index 83661038..53ceba17 100644
--- a/kasa/device_factory.py
+++ b/kasa/device_factory.py
@@ -159,6 +159,7 @@ def get_device_class_from_family(
"SMART.KASAHUB": SmartDevice,
"SMART.KASASWITCH": SmartDevice,
"SMART.IPCAMERA.HTTPS": SmartCamDevice,
+ "SMART.TAPODOORBELL.HTTPS": SmartCamDevice,
"SMART.TAPOROBOVAC.HTTPS": SmartDevice,
"IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb,
@@ -194,7 +195,10 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol
protocol_name = ctype.device_family.value.split(".")[0]
_LOGGER.debug("Finding protocol for %s", ctype.device_family)
- if ctype.device_family is DeviceFamily.SmartIpCamera:
+ if ctype.device_family in {
+ DeviceFamily.SmartIpCamera,
+ DeviceFamily.SmartTapoDoorbell,
+ }:
if strict and ctype.encryption_type is not DeviceEncryptionType.Aes:
return None
return SmartCamProtocol(transport=SslAesTransport(config=config))
diff --git a/kasa/device_type.py b/kasa/device_type.py
index 7fe485d3..d3996217 100755
--- a/kasa/device_type.py
+++ b/kasa/device_type.py
@@ -22,6 +22,8 @@ class DeviceType(Enum):
Fan = "fan"
Thermostat = "thermostat"
Vacuum = "vacuum"
+ Chime = "chime"
+ Doorbell = "doorbell"
Unknown = "unknown"
@staticmethod
diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py
index b6325570..2b669f80 100644
--- a/kasa/deviceconfig.py
+++ b/kasa/deviceconfig.py
@@ -79,6 +79,8 @@ class DeviceFamily(Enum):
SmartKasaHub = "SMART.KASAHUB"
SmartIpCamera = "SMART.IPCAMERA"
SmartTapoRobovac = "SMART.TAPOROBOVAC"
+ SmartTapoChime = "SMART.TAPOCHIME"
+ SmartTapoDoorbell = "SMART.TAPODOORBELL"
class _DeviceConfigBaseMixin(DataClassJSONMixin):
diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py
index ee86b0e2..c668a208 100644
--- a/kasa/smart/smartdevice.py
+++ b/kasa/smart/smartdevice.py
@@ -885,6 +885,8 @@ class SmartDevice(Device):
return DeviceType.Thermostat
if "ROBOVAC" in device_type:
return DeviceType.Vacuum
+ if "TAPOCHIME" in device_type:
+ return DeviceType.Chime
_LOGGER.warning("Unknown device type, falling back to plug")
return DeviceType.Plug
diff --git a/kasa/smartcam/modules/camera.py b/kasa/smartcam/modules/camera.py
index 9a339120..bd4b2808 100644
--- a/kasa/smartcam/modules/camera.py
+++ b/kasa/smartcam/modules/camera.py
@@ -9,7 +9,6 @@ from typing import Annotated
from urllib.parse import quote_plus
from ...credentials import Credentials
-from ...device_type import DeviceType
from ...feature import Feature
from ...json import loads as json_loads
from ...module import FeatureAttribute, Module
@@ -31,6 +30,8 @@ class StreamResolution(StrEnum):
class Camera(SmartCamModule):
"""Implementation of device module."""
+ REQUIRED_COMPONENT = "video"
+
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
if Module.LensMask in self._device.modules:
@@ -126,7 +127,3 @@ class Camera(SmartCamModule):
return None
return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service"
-
- async def _check_supported(self) -> bool:
- """Additional check to see if the module is supported by the device."""
- return self._device.device_type is DeviceType.Camera
diff --git a/kasa/smartcam/smartcamchild.py b/kasa/smartcam/smartcamchild.py
index d1b263b4..d2614464 100644
--- a/kasa/smartcam/smartcamchild.py
+++ b/kasa/smartcam/smartcamchild.py
@@ -85,6 +85,13 @@ class SmartCamChild(SmartChildDevice, SmartCamDevice):
# devices
self._info = self._map_child_info_from_parent(info)
+ @property
+ def device_type(self) -> DeviceType:
+ """Return the device type."""
+ if self._device_type == DeviceType.Unknown and self._info:
+ self._device_type = self._get_device_type_from_sysinfo(self._info)
+ return self._device_type
+
@staticmethod
def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None
diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py
index d096fb5b..fc9d0b92 100644
--- a/kasa/smartcam/smartcamdevice.py
+++ b/kasa/smartcam/smartcamdevice.py
@@ -26,12 +26,15 @@ class SmartCamDevice(SmartDevice):
@staticmethod
def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType:
"""Find type to be displayed as a supported device category."""
- if (
- sysinfo
- and (device_type := sysinfo.get("device_type"))
- and device_type.endswith("HUB")
- ):
+ if not (device_type := sysinfo.get("device_type")):
+ return DeviceType.Unknown
+
+ if device_type.endswith("HUB"):
return DeviceType.Hub
+
+ if "DOORBELL" in device_type:
+ return DeviceType.Doorbell
+
return DeviceType.Camera
@staticmethod
@@ -165,11 +168,6 @@ class SmartCamDevice(SmartDevice):
if (
mod.REQUIRED_COMPONENT
and mod.REQUIRED_COMPONENT not in self._components
- # Always add Camera module to cameras
- and (
- mod._module_name() != Module.Camera
- or self._device_type is not DeviceType.Camera
- )
):
continue
module = mod(self, mod._module_name())
@@ -258,7 +256,7 @@ class SmartCamDevice(SmartDevice):
@property
def device_type(self) -> DeviceType:
"""Return the device type."""
- if self._device_type == DeviceType.Unknown:
+ if self._device_type == DeviceType.Unknown and self._info:
self._device_type = self._get_device_type_from_sysinfo(self._info)
return self._device_type
diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py
index f28b17e3..f6a2dfe4 100644
--- a/tests/device_fixtures.py
+++ b/tests/device_fixtures.py
@@ -131,6 +131,7 @@ SENSORS_SMART = {
"S200D",
"S210",
"S220",
+ "D100C", # needs a home category?
}
THERMOSTATS_SMART = {"KE100"}
@@ -345,6 +346,16 @@ hub_smartcam = parametrize(
device_type_filter=[DeviceType.Hub],
protocol_filter={"SMARTCAM"},
)
+doobell_smartcam = parametrize(
+ "doorbell smartcam",
+ device_type_filter=[DeviceType.Doorbell],
+ protocol_filter={"SMARTCAM", "SMARTCAM.CHILD"},
+)
+chime_smart = parametrize(
+ "chime smart",
+ device_type_filter=[DeviceType.Chime],
+ protocol_filter={"SMART"},
+)
vacuum = parametrize("vacuums", device_type_filter=[DeviceType.Vacuum])
@@ -362,7 +373,9 @@ def check_categories():
+ hubs_smart.args[1]
+ sensors_smart.args[1]
+ thermostats_smart.args[1]
+ + chime_smart.args[1]
+ camera_smartcam.args[1]
+ + doobell_smartcam.args[1]
+ hub_smartcam.args[1]
+ vacuum.args[1]
)
diff --git a/tests/test_device.py b/tests/test_device.py
index 4f74e89c..2c001bc6 100644
--- a/tests/test_device.py
+++ b/tests/test_device.py
@@ -121,19 +121,9 @@ async def test_device_class_repr(device_class_name_obj):
klass = device_class_name_obj[1]
if issubclass(klass, SmartChildDevice | SmartCamChild):
parent = SmartDevice(host, config=config)
- smartcam_required = {
- "device_model": "foo",
- "device_type": "SMART.TAPODOORBELL",
- "alias": "Foo",
- "sw_ver": "1.1",
- "hw_ver": "1.0",
- "mac": "1.2.3.4",
- "hwId": "hw_id",
- "oem_id": "oem_id",
- }
dev = klass(
parent,
- {"dummy": "info", "device_id": "dummy", **smartcam_required},
+ {"dummy": "info", "device_id": "dummy"},
{
"component_list": [{"id": "device", "ver_code": 1}],
"app_component_list": [{"name": "device", "version": 1}],
@@ -153,8 +143,8 @@ async def test_device_class_repr(device_class_name_obj):
IotCamera: DeviceType.Camera,
SmartChildDevice: DeviceType.Unknown,
SmartDevice: DeviceType.Unknown,
- SmartCamDevice: DeviceType.Camera,
- SmartCamChild: DeviceType.Camera,
+ SmartCamDevice: DeviceType.Unknown,
+ SmartCamChild: DeviceType.Unknown,
}
type_ = CLASS_TO_DEFAULT_TYPE[klass]
child_repr = ">"
diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py
index d6bdaedf..539609c3 100644
--- a/tests/test_device_factory.py
+++ b/tests/test_device_factory.py
@@ -245,6 +245,12 @@ ET = DeviceEncryptionType
SslAesTransport,
id="smartcam-hub",
),
+ pytest.param(
+ CP(DF.SmartTapoDoorbell, ET.Aes, https=True),
+ SmartCamProtocol,
+ SslAesTransport,
+ id="smartcam-doorbell",
+ ),
pytest.param(
CP(DF.IotIpCamera, ET.Aes, https=True),
IotProtocol,
@@ -281,6 +287,12 @@ ET = DeviceEncryptionType
KlapTransportV2,
id="smart-klap",
),
+ pytest.param(
+ CP(DF.SmartTapoChime, ET.Klap, https=False),
+ SmartProtocol,
+ KlapTransportV2,
+ id="smart-chime",
+ ),
],
)
async def test_get_protocol(