Add Tapo C460 support (#1645)
Some checks failed
CI / Perform linting checks (3.13) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
CodeQL checks / Analyze (python) (push) Has been cancelled
Stale / stale (push) Has been cancelled

Changes include:
- New smartcam fixture for C460
- Battery module updates to safely handle optional temperature and
voltage fields
This commit is contained in:
Mark Van Praet
2026-02-21 10:20:01 -05:00
committed by GitHub
parent 55f9959777
commit ade64c64af
5 changed files with 1180 additions and 41 deletions

View File

@@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device
- **Wall Switches**: S210, S220, S500, S500D, S505, S505D, TS15 - **Wall Switches**: S210, S220, S500, S500D, S505, S505D, TS15
- **Bulbs**: L430C, L430P, L510B, L510E, L530B, L530E, L535E, L630 - **Bulbs**: L430C, L430P, L510B, L510E, L530B, L530E, L535E, L630
- **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 - **Cameras**: C100, C110, C210, C220, C225, C325WB, C460, C520WS, C720, TC65, TC70
- **Doorbells and chimes**: D100C, D130, D230 - **Doorbells and chimes**: D100C, D130, D230
- **Vacuums**: RV20 Max Plus, RV30 Max - **Vacuums**: RV20 Max Plus, RV30 Max
- **Hubs**: H100, H200 - **Hubs**: H100, H200

View File

@@ -314,6 +314,8 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- Hardware: 2.0 (US) / Firmware: 1.0.11 - Hardware: 2.0 (US) / Firmware: 1.0.11
- **C325WB** - **C325WB**
- Hardware: 1.0 (EU) / Firmware: 1.1.17 - Hardware: 1.0 (EU) / Firmware: 1.1.17
- **C460**
- Hardware: 1.0 (CA) / Firmware: 1.2.0
- **C520WS** - **C520WS**
- Hardware: 1.0 (US) / Firmware: 1.2.8 - Hardware: 1.0 (US) / Firmware: 1.2.8
- **C720** - **C720**

View File

@@ -1,8 +1,9 @@
"""Implementation of baby cry detection module.""" """Implementation of smartcam battery module."""
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
from ...feature import Feature from ...feature import Feature
from ..smartcammodule import SmartCamModule from ..smartcammodule import SmartCamModule
@@ -44,32 +45,37 @@ class Battery(SmartCamModule):
) )
) )
self._add_feature( # Optional on some battery cameras (e.g., C460).
Feature( if self._optional_float_sysinfo("battery_temperature") is not None:
self._device, self._add_feature(
"battery_temperature", Feature(
"Battery temperature", self._device,
container=self, "battery_temperature",
attribute_getter="battery_temperature", "Battery temperature",
icon="mdi:battery", container=self,
unit_getter=lambda: "celsius", attribute_getter="battery_temperature",
category=Feature.Category.Debug, icon="mdi:battery",
type=Feature.Type.Sensor, unit_getter=lambda: "celsius",
category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
) )
)
self._add_feature( if self._optional_float_sysinfo("battery_voltage") is not None:
Feature( self._add_feature(
self._device, Feature(
"battery_voltage", self._device,
"Battery voltage", "battery_voltage",
container=self, "Battery voltage",
attribute_getter="battery_voltage", container=self,
icon="mdi:battery", attribute_getter="battery_voltage",
unit_getter=lambda: "V", icon="mdi:battery",
category=Feature.Category.Debug, unit_getter=lambda: "V",
type=Feature.Type.Sensor, category=Feature.Category.Debug,
type=Feature.Type.Sensor,
)
) )
)
self._add_feature( self._add_feature(
Feature( Feature(
self._device, self._device,
@@ -83,6 +89,18 @@ class Battery(SmartCamModule):
) )
) )
def _optional_float_sysinfo(self, key: str) -> float | None:
"""Return sys_info[key] as float, or None if not available or invalid."""
v_any: Any = self._device.sys_info.get(key)
if v_any in (None, "NO"):
return None
try:
# Accept ints/floats and numeric strings.
return float(v_any)
except (TypeError, ValueError):
return None
def query(self) -> dict: def query(self) -> dict:
"""Query to execute during the update cycle.""" """Query to execute during the update cycle."""
return {} return {}
@@ -98,16 +116,22 @@ class Battery(SmartCamModule):
return self._device.sys_info["low_battery"] return self._device.sys_info["low_battery"]
@property @property
def battery_temperature(self) -> bool: def battery_temperature(self) -> float | None:
"""Return battery voltage in C.""" """Return battery temperature in °C (if available)."""
return self._device.sys_info["battery_temperature"] return self._optional_float_sysinfo("battery_temperature")
@property @property
def battery_voltage(self) -> bool: def battery_voltage(self) -> float | None:
"""Return battery voltage in V.""" """Return battery voltage in V (if available)."""
return self._device.sys_info["battery_voltage"] / 1_000 v = self._optional_float_sysinfo("battery_voltage")
return None if v is None else v / 1_000
@property @property
def battery_charging(self) -> bool: def battery_charging(self) -> bool:
"""Return True if battery is charging.""" """Return True if battery is charging."""
return self._device.sys_info["battery_voltage"] != "NO" v = self._device.sys_info.get("battery_charging")
if isinstance(v, bool):
return v
if v is None:
return False
return str(v).strip().lower() in ("yes", "true", "1", "charging", "on")

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import pytest
from kasa import Device from kasa import Device
from kasa.smartcam.smartcammodule import SmartCamModule from kasa.smartcam.smartcammodule import SmartCamModule
@@ -20,14 +22,69 @@ async def test_battery(dev: Device):
battery = dev.modules.get(SmartCamModule.SmartCamBattery) battery = dev.modules.get(SmartCamModule.SmartCamBattery)
assert battery assert battery
feat_ids = { required = {"battery_level", "battery_low", "battery_charging"}
"battery_level", optional = {"battery_temperature", "battery_voltage"}
"battery_low",
"battery_temperature", for feat_id in required:
"battery_voltage",
"battery_charging",
}
for feat_id in feat_ids:
feat = dev.features.get(feat_id) feat = dev.features.get(feat_id)
assert feat assert feat
assert feat.value is not None assert feat.value is not None
for feat_id in optional:
feat = dev.features.get(feat_id)
if feat is not None:
assert feat.value is not None
@battery_smartcam
@pytest.mark.parametrize(
("raw", "expected"),
[
(None, None), # covers: v in (None, "NO") -> return None
("NO", None), # covers: v in (None, "NO") -> return None
("nonsense", None), # covers: ValueError -> except -> return None
("12.3", 12.3), # sanity: happy path
],
)
async def test_battery_temperature_edge_cases(dev: Device, raw, expected):
battery = dev.modules.get(SmartCamModule.SmartCamBattery)
assert battery
dev.sys_info["battery_temperature"] = raw
assert battery.battery_temperature == expected
@battery_smartcam
@pytest.mark.parametrize(
("voltage_raw", "expected_v"),
[
(None, None), # covers: battery_voltage -> return None
("NO", None), # covers: battery_voltage -> return None
("12000", 12.0), # sanity: parses string -> float(...) / 1000
],
)
async def test_battery_voltage_edge_cases(dev: Device, voltage_raw, expected_v):
battery = dev.modules.get(SmartCamModule.SmartCamBattery)
assert battery
dev.sys_info["battery_voltage"] = voltage_raw
assert battery.battery_voltage == expected_v
@battery_smartcam
@pytest.mark.parametrize(
("charging_raw", "expected"),
[
(True, True), # covers: isinstance(v, bool) -> return v
(False, False), # covers: isinstance(v, bool) -> return v
(None, False), # covers: v is None -> return False
("yes", True), # sanity: string normalization path
("NO", False), # sanity: string normalization path
],
)
async def test_battery_charging_edge_cases(dev: Device, charging_raw, expected):
battery = dev.modules.get(SmartCamModule.SmartCamBattery)
assert battery
dev.sys_info["battery_charging"] = charging_raw
assert battery.battery_charging is expected