Move Switcher handle_coordinator_update to base entity (#143738)

This commit is contained in:
Shay Levy 2025-04-27 00:01:44 +03:00 committed by GitHub
parent 40752dcfb6
commit 868b8ad318
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 180 additions and 84 deletions

View File

@ -6,14 +6,10 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, cast from typing import Any, cast
from aioswitcher.api import ( from aioswitcher.api import SwitcherApi
DeviceState, from aioswitcher.api.messages import SwitcherBaseResponse
SwitcherApi,
SwitcherBaseResponse,
ThermostatSwing,
)
from aioswitcher.api.remotes import SwitcherBreezeRemote from aioswitcher.api.remotes import SwitcherBreezeRemote
from aioswitcher.device import DeviceCategory from aioswitcher.device import DeviceCategory, DeviceState, ThermostatSwing
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory

View File

@ -26,7 +26,7 @@ from homeassistant.components.climate import (
HVACMode, HVACMode,
) )
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -117,20 +117,15 @@ class SwitcherClimateEntity(SwitcherEntity, ClimateEntity):
self._attr_supported_features |= ( self._attr_supported_features |= (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
) )
self._update_data(True)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_data() self._update_data()
self.async_write_ha_state()
def _update_data(self, force_update: bool = False) -> None: def _update_data(self) -> None:
"""Update data from device.""" """Update data from device."""
data = cast(SwitcherThermostat, self.coordinator.data) data = cast(SwitcherThermostat, self.coordinator.data)
features = self._remote.modes_features[data.mode] features = self._remote.modes_features[data.mode]
if data.target_temperature == 0 and not force_update: # Ignore empty update from device that was power cycled
if data.target_temperature == 0 and self.target_temperature is not None:
return return
self._attr_current_temperature = data.temperature self._attr_current_temperature = data.temperature

View File

@ -6,7 +6,7 @@ from collections.abc import Mapping
import logging import logging
from typing import Any, Final from typing import Any, Final
from aioswitcher.bridge import SwitcherBase from aioswitcher.device import SwitcherBase
from aioswitcher.device.tools import validate_token from aioswitcher.device.tools import validate_token
import voluptuous as vol import voluptuous as vol

View File

@ -69,12 +69,6 @@ class SwitcherBaseCoverEntity(SwitcherEntity, CoverEntity):
) )
_cover_id: int _cover_id: int
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_data()
self.async_write_ha_state()
def _update_data(self) -> None: def _update_data(self) -> None:
"""Update data from device.""" """Update data from device."""
data = cast(SwitcherShutter, self.coordinator.data) data = cast(SwitcherShutter, self.coordinator.data)

View File

@ -6,6 +6,7 @@ from typing import Any
from aioswitcher.api import SwitcherApi from aioswitcher.api import SwitcherApi
from aioswitcher.api.messages import SwitcherBaseResponse from aioswitcher.api.messages import SwitcherBaseResponse
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -28,6 +29,15 @@ class SwitcherEntity(CoordinatorEntity[SwitcherDataUpdateCoordinator]):
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)}
) )
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_data()
super()._handle_coordinator_update()
def _update_data(self) -> None:
"""Update data from device."""
async def _async_call_api(self, api: str, *args: Any, **kwargs: Any) -> None: async def _async_call_api(self, api: str, *args: Any, **kwargs: Any) -> None:
"""Call Switcher API.""" """Call Switcher API."""
_LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args)

View File

@ -68,32 +68,28 @@ class SwitcherBaseLightEntity(SwitcherEntity, LightEntity):
super().__init__(coordinator) super().__init__(coordinator)
self._light_id = light_id self._light_id = light_id
self.control_result: bool | None = None self.control_result: bool | None = None
self._update_data()
@callback def _update_data(self) -> None:
def _handle_coordinator_update(self) -> None: """Update data from device."""
"""When device updates, clear control result that overrides state."""
self.control_result = None
self.async_write_ha_state()
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
if self.control_result is not None: if self.control_result is not None:
return self.control_result self._attr_is_on = self.control_result
self.control_result = None
return
data = cast(SwitcherLight, self.coordinator.data) data = cast(SwitcherLight, self.coordinator.data)
return bool(data.light[self._light_id] == DeviceState.ON) self._attr_is_on = bool(data.light[self._light_id] == DeviceState.ON)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on.""" """Turn the light on."""
await self._async_call_api(API_SET_LIGHT, DeviceState.ON, self._light_id) await self._async_call_api(API_SET_LIGHT, DeviceState.ON, self._light_id)
self.control_result = True self._attr_is_on = self.control_result = True
self.async_write_ha_state() self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off.""" """Turn the light off."""
await self._async_call_api(API_SET_LIGHT, DeviceState.OFF, self._light_id) await self._async_call_api(API_SET_LIGHT, DeviceState.OFF, self._light_id)
self.control_result = False self._attr_is_on = self.control_result = False
self.async_write_ha_state() self.async_write_ha_state()
@ -109,8 +105,6 @@ class SwitcherSingleLightEntity(SwitcherBaseLightEntity):
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, light_id) super().__init__(coordinator, light_id)
# Entity class attributes
self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}"
@ -126,8 +120,6 @@ class SwitcherMultiLightEntity(SwitcherBaseLightEntity):
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, light_id) super().__init__(coordinator, light_id)
# Entity class attributes
self._attr_translation_placeholders = {"light_id": str(light_id + 1)} self._attr_translation_placeholders = {"light_id": str(light_id + 1)}
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator.device_id}-{coordinator.mac_address}-{light_id}" f"{coordinator.device_id}-{coordinator.mac_address}-{light_id}"

View File

@ -111,34 +111,28 @@ class SwitcherBaseSwitchEntity(SwitcherEntity, SwitchEntity):
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.control_result: bool | None = None self.control_result: bool | None = None
# Entity class attributes
self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}"
self._update_data()
@callback def _update_data(self) -> None:
def _handle_coordinator_update(self) -> None: """Update data from device."""
"""When device updates, clear control result that overrides state."""
self.control_result = None
self.async_write_ha_state()
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
if self.control_result is not None: if self.control_result is not None:
return self.control_result self._attr_is_on = self.control_result
self.control_result = None
return
return bool(self.coordinator.data.device_state == DeviceState.ON) self._attr_is_on = bool(self.coordinator.data.device_state == DeviceState.ON)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """Turn the entity on."""
await self._async_call_api(API_CONTROL_DEVICE, Command.ON) await self._async_call_api(API_CONTROL_DEVICE, Command.ON)
self.control_result = True self._attr_is_on = self.control_result = True
self.async_write_ha_state() self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off.""" """Turn the entity off."""
await self._async_call_api(API_CONTROL_DEVICE, Command.OFF) await self._async_call_api(API_CONTROL_DEVICE, Command.OFF)
self.control_result = False self._attr_is_on = self.control_result = False
self.async_write_ha_state() self.async_write_ha_state()
async def async_set_auto_off_service(self, auto_off: timedelta) -> None: async def async_set_auto_off_service(self, auto_off: timedelta) -> None:
@ -177,44 +171,45 @@ class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity):
async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None:
"""Use for turning device on with a timer service calls.""" """Use for turning device on with a timer service calls."""
await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes) await self._async_call_api(API_CONTROL_DEVICE, Command.ON, timer_minutes)
self.control_result = True self._attr_is_on = self.control_result = True
self.async_write_ha_state() self.async_write_ha_state()
class SwitcherShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity): class SwitcherShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity):
"""Representation of a Switcher shutter base switch entity.""" """Representation of a Switcher child lock base switch entity."""
_attr_device_class = SwitchDeviceClass.SWITCH _attr_device_class = SwitchDeviceClass.SWITCH
_attr_entity_category = EntityCategory.CONFIG _attr_entity_category = EntityCategory.CONFIG
_attr_icon = "mdi:lock-open" _attr_icon = "mdi:lock-open"
_cover_id: int _cover_id: int
def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: def __init__(
self,
coordinator: SwitcherDataUpdateCoordinator,
cover_id: int,
) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._cover_id = cover_id
self.control_result: bool | None = None self.control_result: bool | None = None
self._update_data()
@callback def _update_data(self) -> None:
def _handle_coordinator_update(self) -> None: """Update data from device."""
"""When device updates, clear control result that overrides state."""
self.control_result = None
super()._handle_coordinator_update()
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
if self.control_result is not None: if self.control_result is not None:
return self.control_result self._attr_is_on = self.control_result
self.control_result = None
return
data = cast(SwitcherShutter, self.coordinator.data) data = cast(SwitcherShutter, self.coordinator.data)
return bool(data.child_lock[self._cover_id] == ShutterChildLock.ON) self._attr_is_on = bool(data.child_lock[self._cover_id] == ShutterChildLock.ON)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """Turn the entity on."""
await self._async_call_api( await self._async_call_api(
API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id API_SET_CHILD_LOCK, ShutterChildLock.ON, self._cover_id
) )
self.control_result = True self._attr_is_on = self.control_result = True
self.async_write_ha_state() self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
@ -222,7 +217,7 @@ class SwitcherShutterChildLockBaseSwitchEntity(SwitcherEntity, SwitchEntity):
await self._async_call_api( await self._async_call_api(
API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id API_SET_CHILD_LOCK, ShutterChildLock.OFF, self._cover_id
) )
self.control_result = False self._attr_is_on = self.control_result = False
self.async_write_ha_state() self.async_write_ha_state()
@ -239,9 +234,7 @@ class SwitcherShutterChildLockSingleSwitchEntity(
cover_id: int, cover_id: int,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator, cover_id)
self._cover_id = cover_id
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator.device_id}-{coordinator.mac_address}-child_lock" f"{coordinator.device_id}-{coordinator.mac_address}-child_lock"
) )
@ -260,8 +253,7 @@ class SwitcherShutterChildLockMultiSwitchEntity(
cover_id: int, cover_id: int,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator, cover_id)
self._cover_id = cover_id
self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)} self._attr_translation_placeholders = {"cover_id": str(cover_id + 1)}
self._attr_unique_id = ( self._attr_unique_id = (

View File

@ -6,7 +6,8 @@ import asyncio
import logging import logging
from aioswitcher.api.remotes import SwitcherBreezeRemoteManager from aioswitcher.api.remotes import SwitcherBreezeRemoteManager
from aioswitcher.bridge import SwitcherBase, SwitcherBridge from aioswitcher.bridge import SwitcherBridge
from aioswitcher.device import SwitcherBase
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton from homeassistant.helpers import singleton

View File

@ -2,7 +2,8 @@
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
from aioswitcher.api import DeviceState, SwitcherBaseResponse, ThermostatSwing from aioswitcher.api.messages import SwitcherBaseResponse
from aioswitcher.device import DeviceState, ThermostatSwing
import pytest import pytest
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS

View File

@ -2,7 +2,7 @@
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
from aioswitcher.api import SwitcherBaseResponse from aioswitcher.api.messages import SwitcherBaseResponse
from aioswitcher.device import ( from aioswitcher.device import (
DeviceState, DeviceState,
ThermostatFanLevel, ThermostatFanLevel,

View File

@ -2,7 +2,7 @@
from unittest.mock import patch from unittest.mock import patch
from aioswitcher.api import SwitcherBaseResponse from aioswitcher.api.messages import SwitcherBaseResponse
from aioswitcher.device import ShutterDirection from aioswitcher.device import ShutterDirection
import pytest import pytest

View File

@ -2,7 +2,7 @@
from unittest.mock import patch from unittest.mock import patch
from aioswitcher.api import SwitcherBaseResponse from aioswitcher.api.messages import SwitcherBaseResponse
from aioswitcher.device import DeviceState from aioswitcher.device import DeviceState
import pytest import pytest
@ -111,6 +111,44 @@ async def test_light(
assert state.state == STATE_OFF assert state.state == STATE_OFF
@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True)
async def test_light_ignore_previous_async_state(
hass: HomeAssistant, mock_bridge, mock_api
) -> None:
"""Test light ignores previous async state."""
await init_integration(hass, USERNAME, TOKEN)
assert mock_bridge
entity_id = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_1"
# Test initial state - light on
state = hass.states.get(entity_id)
assert state.state == STATE_ON
# Test turning off light
with patch(
"homeassistant.components.switcher_kis.entity.SwitcherApi.set_light"
) as mock_set_light:
await hass.services.async_call(
LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
# Push old state and makge sure it is ignored
mock_bridge.mock_callbacks([DEVICE])
await hass.async_block_till_done()
assert mock_api.call_count == 2
mock_set_light.assert_called_once_with(DeviceState.OFF, 0)
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
# Verify new state is not ignored
mock_bridge.mock_callbacks([DEVICE])
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == STATE_ON
@pytest.mark.parametrize( @pytest.mark.parametrize(
("device", "entity_id", "light_id", "device_state"), ("device", "entity_id", "light_id", "device_state"),
[ [
@ -133,7 +171,6 @@ async def test_light_control_fail(
mock_bridge, mock_bridge,
mock_api, mock_api,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
device, device,
entity_id: str, entity_id: str,
light_id: int, light_id: int,

View File

@ -2,8 +2,9 @@
from unittest.mock import patch from unittest.mock import patch
from aioswitcher.api import Command, ShutterChildLock, SwitcherBaseResponse from aioswitcher.api import Command
from aioswitcher.device import DeviceState from aioswitcher.api.messages import SwitcherBaseResponse
from aioswitcher.device import DeviceState, ShutterChildLock
import pytest import pytest
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
@ -86,6 +87,45 @@ async def test_switch(
assert state.state == STATE_OFF assert state.state == STATE_OFF
@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True)
async def test_switch_ignore_previous_async_state(
hass: HomeAssistant, mock_bridge, mock_api
) -> None:
"""Test switch ignores previous async state."""
await init_integration(hass)
assert mock_bridge
device = DUMMY_WATER_HEATER_DEVICE
entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}"
# Test initial state - on
state = hass.states.get(entity_id)
assert state.state == STATE_ON
# Test turning off
with patch(
"homeassistant.components.switcher_kis.entity.SwitcherApi.control_device"
) as mock_control_device:
await hass.services.async_call(
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
# Push old state and makge sure it is ignored
mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE])
await hass.async_block_till_done()
assert mock_api.call_count == 2
mock_control_device.assert_called_once_with(Command.OFF)
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
# Verify new state is not ignored
mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE])
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == STATE_ON
@pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True) @pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True)
async def test_switch_control_fail( async def test_switch_control_fail(
hass: HomeAssistant, hass: HomeAssistant,
@ -240,6 +280,44 @@ async def test_child_lock_switch(
assert state.state == STATE_OFF assert state.state == STATE_OFF
@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True)
async def test_child_lock_switch_ignore_previous_async_state(
hass: HomeAssistant, mock_bridge, mock_api
) -> None:
"""Test child lock switch ignores previous async state."""
await init_integration(hass)
assert mock_bridge
entity_id = f"{SWITCH_DOMAIN}.{slugify(DEVICE.name)}_child_lock"
# Test initial state - on
state = hass.states.get(entity_id)
assert state.state == STATE_ON
# Test turning off
with patch(
"homeassistant.components.switcher_kis.entity.SwitcherApi.set_shutter_child_lock"
) as mock_control_device:
await hass.services.async_call(
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
# Push old state and makge sure it is ignored
mock_bridge.mock_callbacks([DEVICE])
await hass.async_block_till_done()
assert mock_api.call_count == 2
mock_control_device.assert_called_once_with(ShutterChildLock.OFF, 0)
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
# Verify new state is not ignored
mock_bridge.mock_callbacks([DEVICE])
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == STATE_ON
@pytest.mark.parametrize( @pytest.mark.parametrize(
( (
"device", "device",