Improve overheat reporting (#1335)
Some checks are pending
CI / Perform linting checks (3.13) (push) Waiting to run
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
CodeQL checks / Analyze (python) (push) Waiting to run

Different devices and different firmwares report overheated status in
different ways.
Some devices indicate support for `overheat_protect` component, but
there are devices that report `overheat_status` even when it is not
listed.
Some other devices use `overheated` boolean that was already previously
supported, but this PR adds support for much more devices that use
`overheat_status` for reporting.

The "overheated" feature is moved into its own module, and uses either
of the ways to report this information.
This will also rename `REQUIRED_KEY_ON_PARENT` to `SYSINFO_LOOKUP_KEYS`
and change its logic to check if any of the keys in the list are found
in the sysinfo.

```
tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheat_protect' -c|wc -l
15
tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheated' -c|wc -l
38
tpr@lumipyry ~/c/p/tests (fix/overheated)> ag 'overheat_status' -c|wc -l
20
```

---------

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
This commit is contained in:
Teemu R. 2024-12-11 01:01:36 +01:00 committed by GitHub
parent bf8f0adabe
commit 032cd5d2cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 108 additions and 21 deletions

View File

@ -91,5 +91,5 @@ False
True True
>>> for feat in dev.features.values(): >>> for feat in dev.features.values():
>>> print(f"{feat.name}: {feat.value}") >>> print(f"{feat.name}: {feat.value}")
Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: <Action>\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: <Action>\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nReboot: <Action>\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: <Action>\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False\nDevice time: 2024-02-23 02:40:15+01:00
""" """

View File

@ -24,7 +24,6 @@ State (state): True
Signal Level (signal_level): 2 Signal Level (signal_level): 2
RSSI (rssi): -52 RSSI (rssi): -52
SSID (ssid): #MASKED_SSID# SSID (ssid): #MASKED_SSID#
Overheated (overheated): False
Reboot (reboot): <Action> Reboot (reboot): <Action>
Brightness (brightness): 100 Brightness (brightness): 100
Cloud connection (cloud_connection): True Cloud connection (cloud_connection): True
@ -39,6 +38,7 @@ Light effect (light_effect): Off
Light preset (light_preset): Not set Light preset (light_preset): Not set
Smooth transition on (smooth_transition_on): 2 Smooth transition on (smooth_transition_on): 2
Smooth transition off (smooth_transition_off): 2 Smooth transition off (smooth_transition_off): 2
Overheated (overheated): False
Device time (device_time): 2024-02-23 02:40:15+01:00 Device time (device_time): 2024-02-23 02:40:15+01:00
To see whether a device supports a feature, check for the existence of it: To see whether a device supports a feature, check for the existence of it:

View File

@ -24,6 +24,7 @@ from .lightpreset import LightPreset
from .lightstripeffect import LightStripEffect from .lightstripeffect import LightStripEffect
from .lighttransition import LightTransition from .lighttransition import LightTransition
from .motionsensor import MotionSensor from .motionsensor import MotionSensor
from .overheatprotection import OverheatProtection
from .reportmode import ReportMode from .reportmode import ReportMode
from .temperaturecontrol import TemperatureControl from .temperaturecontrol import TemperatureControl
from .temperaturesensor import TemperatureSensor from .temperaturesensor import TemperatureSensor
@ -64,4 +65,5 @@ __all__ = [
"FrostProtection", "FrostProtection",
"Thermostat", "Thermostat",
"SmartLightEffect", "SmartLightEffect",
"OverheatProtection",
] ]

View File

@ -10,7 +10,7 @@ class ContactSensor(SmartModule):
"""Implementation of contact sensor module.""" """Implementation of contact sensor module."""
REQUIRED_COMPONENT = None # we depend on availability of key REQUIRED_COMPONENT = None # we depend on availability of key
REQUIRED_KEY_ON_PARENT = "open" SYSINFO_LOOKUP_KEYS = ["open"]
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features after the initial update.""" """Initialize features after the initial update."""

View File

@ -0,0 +1,41 @@
"""Overheat module."""
from __future__ import annotations
from ...feature import Feature
from ..smartmodule import SmartModule
class OverheatProtection(SmartModule):
"""Implementation for overheat_protection."""
SYSINFO_LOOKUP_KEYS = ["overheated", "overheat_status"]
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
container=self,
id="overheated",
name="Overheated",
attribute_getter="overheated",
icon="mdi:heat-wave",
type=Feature.Type.BinarySensor,
category=Feature.Category.Info,
)
)
@property
def overheated(self) -> bool:
"""Return True if device reports overheating."""
if (value := self._device.sys_info.get("overheat_status")) is not None:
# Value can be normal, cooldown, or overheated.
# We report all but normal as overheated.
return value != "normal"
return self._device.sys_info["overheated"]
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {}

View File

@ -349,9 +349,8 @@ class SmartDevice(Device):
) or mod.__name__ in child_modules_to_skip: ) or mod.__name__ in child_modules_to_skip:
continue continue
required_component = cast(str, mod.REQUIRED_COMPONENT) required_component = cast(str, mod.REQUIRED_COMPONENT)
if required_component in self._components or ( if required_component in self._components or any(
mod.REQUIRED_KEY_ON_PARENT self.sys_info.get(key) is not None for key in mod.SYSINFO_LOOKUP_KEYS
and self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None
): ):
_LOGGER.debug( _LOGGER.debug(
"Device %s, found required %s, adding %s to modules.", "Device %s, found required %s, adding %s to modules.",
@ -440,19 +439,6 @@ class SmartDevice(Device):
) )
) )
if "overheated" in self._info:
self._add_feature(
Feature(
self,
id="overheated",
name="Overheated",
attribute_getter=lambda x: x._info["overheated"],
icon="mdi:heat-wave",
type=Feature.Type.BinarySensor,
category=Feature.Category.Info,
)
)
# We check for the key available, and not for the property truthiness, # We check for the key available, and not for the property truthiness,
# as the value is falsy when the device is off. # as the value is falsy when the device is off.
if "on_time" in self._info: if "on_time" in self._info:

View File

@ -54,8 +54,8 @@ class SmartModule(Module):
NAME: str NAME: str
#: Module is initialized, if the given component is available #: Module is initialized, if the given component is available
REQUIRED_COMPONENT: str | None = None REQUIRED_COMPONENT: str | None = None
#: Module is initialized, if the given key available in the main sysinfo #: Module is initialized, if any of the given keys exists in the sysinfo
REQUIRED_KEY_ON_PARENT: str | None = None SYSINFO_LOOKUP_KEYS: list[str] = []
#: Query to execute during the main update cycle #: Query to execute during the main update cycle
QUERY_GETTER_NAME: str QUERY_GETTER_NAME: str

View File

@ -470,3 +470,61 @@ async def test_smart_temp_range(dev: Device):
light = dev.modules.get(Module.Light) light = dev.modules.get(Module.Light)
assert light assert light
assert light.valid_temperature_range assert light.valid_temperature_range
@device_smart
async def test_initialize_modules_sysinfo_lookup_keys(
dev: SmartDevice, mocker: MockerFixture
):
"""Test that matching modules using SYSINFO_LOOKUP_KEYS are initialized correctly."""
class AvailableKey(SmartModule):
SYSINFO_LOOKUP_KEYS = ["device_id"]
class NonExistingKey(SmartModule):
SYSINFO_LOOKUP_KEYS = ["this_does_not_exist"]
# The __init_subclass__ hook in smartmodule checks the path,
# so we have to manually add these for testing.
mocker.patch.dict(
"kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES",
{
AvailableKey._module_name(): AvailableKey,
NonExistingKey._module_name(): NonExistingKey,
},
)
# We have an already initialized device, so we try to initialize the modules again
await dev._initialize_modules()
assert "AvailableKey" in dev.modules
assert "NonExistingKey" not in dev.modules
@device_smart
async def test_initialize_modules_required_component(
dev: SmartDevice, mocker: MockerFixture
):
"""Test that matching modules using REQUIRED_COMPONENT are initialized correctly."""
class AvailableComponent(SmartModule):
REQUIRED_COMPONENT = "device"
class NonExistingComponent(SmartModule):
REQUIRED_COMPONENT = "this_does_not_exist"
# The __init_subclass__ hook in smartmodule checks the path,
# so we have to manually add these for testing.
mocker.patch.dict(
"kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES",
{
AvailableComponent._module_name(): AvailableComponent,
NonExistingComponent._module_name(): NonExistingComponent,
},
)
# We have an already initialized device, so we try to initialize the modules again
await dev._initialize_modules()
assert "AvailableComponent" in dev.modules
assert "NonExistingComponent" not in dev.modules