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>
This commit is contained in:
steveredden 2025-01-23 03:22:41 -06:00 committed by GitHub
parent acc0e9a80a
commit 54bb53899e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 75 additions and 42 deletions

View File

@ -201,10 +201,11 @@ The following devices have been tested and confirmed as working. If your device
- **Wall Switches**: S210, S220, S500D, S505, S505D - **Wall Switches**: S210, S220, S500D, S505, S505D
- **Bulbs**: L510B, L510E, L530E, L630 - **Bulbs**: L510B, L510E, L530E, L630
- **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **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 - **Hubs**: H100, H200
- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
- **Vacuums**: RV20 Max Plus, RV30 Max
<!--SUPPORTED_END--> <!--SUPPORTED_END-->
[^1]: Model requires authentication [^1]: Model requires authentication

View File

@ -285,13 +285,23 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- Hardware: 1.0 (US) / Firmware: 1.2.8 - Hardware: 1.0 (US) / Firmware: 1.2.8
- **C720** - **C720**
- Hardware: 1.0 (US) / Firmware: 1.2.3 - Hardware: 1.0 (US) / Firmware: 1.2.3
- **D230**
- Hardware: 1.20 (EU) / Firmware: 1.1.19
- **TC65** - **TC65**
- Hardware: 1.0 / Firmware: 1.3.9 - Hardware: 1.0 / Firmware: 1.3.9
- **TC70** - **TC70**
- Hardware: 3.0 / Firmware: 1.3.11 - 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 ### Hubs
- **H100** - **H100**
@ -326,13 +336,6 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- Hardware: 1.0 (EU) / Firmware: 1.7.0 - Hardware: 1.0 (EU) / Firmware: 1.7.0
- Hardware: 1.0 (US) / Firmware: 1.8.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
<!--SUPPORTED_END--> <!--SUPPORTED_END-->
[^1]: Model requires authentication [^1]: Model requires authentication

View File

@ -36,10 +36,12 @@ DEVICE_TYPE_TO_PRODUCT_GROUP = {
DeviceType.Bulb: "Bulbs", DeviceType.Bulb: "Bulbs",
DeviceType.LightStrip: "Light Strips", DeviceType.LightStrip: "Light Strips",
DeviceType.Camera: "Cameras", DeviceType.Camera: "Cameras",
DeviceType.Doorbell: "Doorbells and chimes",
DeviceType.Chime: "Doorbells and chimes",
DeviceType.Vacuum: "Vacuums",
DeviceType.Hub: "Hubs", DeviceType.Hub: "Hubs",
DeviceType.Sensor: "Hub-Connected Devices", DeviceType.Sensor: "Hub-Connected Devices",
DeviceType.Thermostat: "Hub-Connected Devices", DeviceType.Thermostat: "Hub-Connected Devices",
DeviceType.Vacuum: "Vacuums",
} }

View File

@ -159,6 +159,7 @@ def get_device_class_from_family(
"SMART.KASAHUB": SmartDevice, "SMART.KASAHUB": SmartDevice,
"SMART.KASASWITCH": SmartDevice, "SMART.KASASWITCH": SmartDevice,
"SMART.IPCAMERA.HTTPS": SmartCamDevice, "SMART.IPCAMERA.HTTPS": SmartCamDevice,
"SMART.TAPODOORBELL.HTTPS": SmartCamDevice,
"SMART.TAPOROBOVAC.HTTPS": SmartDevice, "SMART.TAPOROBOVAC.HTTPS": SmartDevice,
"IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb, "IOT.SMARTBULB": IotBulb,
@ -194,7 +195,10 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol
protocol_name = ctype.device_family.value.split(".")[0] protocol_name = ctype.device_family.value.split(".")[0]
_LOGGER.debug("Finding protocol for %s", ctype.device_family) _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: if strict and ctype.encryption_type is not DeviceEncryptionType.Aes:
return None return None
return SmartCamProtocol(transport=SslAesTransport(config=config)) return SmartCamProtocol(transport=SslAesTransport(config=config))

View File

@ -22,6 +22,8 @@ class DeviceType(Enum):
Fan = "fan" Fan = "fan"
Thermostat = "thermostat" Thermostat = "thermostat"
Vacuum = "vacuum" Vacuum = "vacuum"
Chime = "chime"
Doorbell = "doorbell"
Unknown = "unknown" Unknown = "unknown"
@staticmethod @staticmethod

View File

@ -79,6 +79,8 @@ class DeviceFamily(Enum):
SmartKasaHub = "SMART.KASAHUB" SmartKasaHub = "SMART.KASAHUB"
SmartIpCamera = "SMART.IPCAMERA" SmartIpCamera = "SMART.IPCAMERA"
SmartTapoRobovac = "SMART.TAPOROBOVAC" SmartTapoRobovac = "SMART.TAPOROBOVAC"
SmartTapoChime = "SMART.TAPOCHIME"
SmartTapoDoorbell = "SMART.TAPODOORBELL"
class _DeviceConfigBaseMixin(DataClassJSONMixin): class _DeviceConfigBaseMixin(DataClassJSONMixin):

View File

@ -885,6 +885,8 @@ class SmartDevice(Device):
return DeviceType.Thermostat return DeviceType.Thermostat
if "ROBOVAC" in device_type: if "ROBOVAC" in device_type:
return DeviceType.Vacuum return DeviceType.Vacuum
if "TAPOCHIME" in device_type:
return DeviceType.Chime
_LOGGER.warning("Unknown device type, falling back to plug") _LOGGER.warning("Unknown device type, falling back to plug")
return DeviceType.Plug return DeviceType.Plug

View File

@ -9,7 +9,6 @@ from typing import Annotated
from urllib.parse import quote_plus from urllib.parse import quote_plus
from ...credentials import Credentials from ...credentials import Credentials
from ...device_type import DeviceType
from ...feature import Feature from ...feature import Feature
from ...json import loads as json_loads from ...json import loads as json_loads
from ...module import FeatureAttribute, Module from ...module import FeatureAttribute, Module
@ -31,6 +30,8 @@ class StreamResolution(StrEnum):
class Camera(SmartCamModule): class Camera(SmartCamModule):
"""Implementation of device module.""" """Implementation of device module."""
REQUIRED_COMPONENT = "video"
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features after the initial update.""" """Initialize features after the initial update."""
if Module.LensMask in self._device.modules: if Module.LensMask in self._device.modules:
@ -126,7 +127,3 @@ class Camera(SmartCamModule):
return None return None
return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service" 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

View File

@ -85,6 +85,13 @@ class SmartCamChild(SmartChildDevice, SmartCamDevice):
# devices # devices
self._info = self._map_child_info_from_parent(info) 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 @staticmethod
def _get_device_info( def _get_device_info(
info: dict[str, Any], discovery_info: dict[str, Any] | None info: dict[str, Any], discovery_info: dict[str, Any] | None

View File

@ -26,12 +26,15 @@ class SmartCamDevice(SmartDevice):
@staticmethod @staticmethod
def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType:
"""Find type to be displayed as a supported device category.""" """Find type to be displayed as a supported device category."""
if ( if not (device_type := sysinfo.get("device_type")):
sysinfo return DeviceType.Unknown
and (device_type := sysinfo.get("device_type"))
and device_type.endswith("HUB") if device_type.endswith("HUB"):
):
return DeviceType.Hub return DeviceType.Hub
if "DOORBELL" in device_type:
return DeviceType.Doorbell
return DeviceType.Camera return DeviceType.Camera
@staticmethod @staticmethod
@ -165,11 +168,6 @@ class SmartCamDevice(SmartDevice):
if ( if (
mod.REQUIRED_COMPONENT mod.REQUIRED_COMPONENT
and mod.REQUIRED_COMPONENT not in self._components 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 continue
module = mod(self, mod._module_name()) module = mod(self, mod._module_name())
@ -258,7 +256,7 @@ class SmartCamDevice(SmartDevice):
@property @property
def device_type(self) -> DeviceType: def device_type(self) -> DeviceType:
"""Return the device type.""" """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) self._device_type = self._get_device_type_from_sysinfo(self._info)
return self._device_type return self._device_type

View File

@ -131,6 +131,7 @@ SENSORS_SMART = {
"S200D", "S200D",
"S210", "S210",
"S220", "S220",
"D100C", # needs a home category?
} }
THERMOSTATS_SMART = {"KE100"} THERMOSTATS_SMART = {"KE100"}
@ -345,6 +346,16 @@ hub_smartcam = parametrize(
device_type_filter=[DeviceType.Hub], device_type_filter=[DeviceType.Hub],
protocol_filter={"SMARTCAM"}, 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]) vacuum = parametrize("vacuums", device_type_filter=[DeviceType.Vacuum])
@ -362,7 +373,9 @@ def check_categories():
+ hubs_smart.args[1] + hubs_smart.args[1]
+ sensors_smart.args[1] + sensors_smart.args[1]
+ thermostats_smart.args[1] + thermostats_smart.args[1]
+ chime_smart.args[1]
+ camera_smartcam.args[1] + camera_smartcam.args[1]
+ doobell_smartcam.args[1]
+ hub_smartcam.args[1] + hub_smartcam.args[1]
+ vacuum.args[1] + vacuum.args[1]
) )

View File

@ -121,19 +121,9 @@ async def test_device_class_repr(device_class_name_obj):
klass = device_class_name_obj[1] klass = device_class_name_obj[1]
if issubclass(klass, SmartChildDevice | SmartCamChild): if issubclass(klass, SmartChildDevice | SmartCamChild):
parent = SmartDevice(host, config=config) 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( dev = klass(
parent, parent,
{"dummy": "info", "device_id": "dummy", **smartcam_required}, {"dummy": "info", "device_id": "dummy"},
{ {
"component_list": [{"id": "device", "ver_code": 1}], "component_list": [{"id": "device", "ver_code": 1}],
"app_component_list": [{"name": "device", "version": 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, IotCamera: DeviceType.Camera,
SmartChildDevice: DeviceType.Unknown, SmartChildDevice: DeviceType.Unknown,
SmartDevice: DeviceType.Unknown, SmartDevice: DeviceType.Unknown,
SmartCamDevice: DeviceType.Camera, SmartCamDevice: DeviceType.Unknown,
SmartCamChild: DeviceType.Camera, SmartCamChild: DeviceType.Unknown,
} }
type_ = CLASS_TO_DEFAULT_TYPE[klass] type_ = CLASS_TO_DEFAULT_TYPE[klass]
child_repr = "<DeviceType.Unknown(child) of <DeviceType.Unknown at 127.0.0.2 - update() needed>>" child_repr = "<DeviceType.Unknown(child) of <DeviceType.Unknown at 127.0.0.2 - update() needed>>"

View File

@ -245,6 +245,12 @@ ET = DeviceEncryptionType
SslAesTransport, SslAesTransport,
id="smartcam-hub", id="smartcam-hub",
), ),
pytest.param(
CP(DF.SmartTapoDoorbell, ET.Aes, https=True),
SmartCamProtocol,
SslAesTransport,
id="smartcam-doorbell",
),
pytest.param( pytest.param(
CP(DF.IotIpCamera, ET.Aes, https=True), CP(DF.IotIpCamera, ET.Aes, https=True),
IotProtocol, IotProtocol,
@ -281,6 +287,12 @@ ET = DeviceEncryptionType
KlapTransportV2, KlapTransportV2,
id="smart-klap", id="smart-klap",
), ),
pytest.param(
CP(DF.SmartTapoChime, ET.Klap, https=False),
SmartProtocol,
KlapTransportV2,
id="smart-chime",
),
], ],
) )
async def test_get_protocol( async def test_get_protocol(