mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-02-26 12:49:56 +00:00
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
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:
@@ -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
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
1056
tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json
vendored
Normal file
1056
tests/fixtures/smartcam/C460(CA)_1.0_1.2.0.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user