Add Cover platform to Iotty (#125422)

* fadd cover entity and device with mocked commands

* add cover features and update its open percentage

* execute command to the cloud instead of mocking change of shutter state

* test iotty cover commands and insertion

* fix post payload

* refactor introducing common entity from which cover and switch inherit

* move more properties to base class

* use explicit values instead of snapshots

* move iotty device initialization to base entity

* move device info from property to attribute
This commit is contained in:
shapournemati-iotty 2024-09-13 14:55:53 +02:00 committed by GitHub
parent eae4618c52
commit 1cea791245
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 561 additions and 33 deletions

View File

@ -19,7 +19,7 @@ from . import coordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SWITCH] PLATFORMS: list[Platform] = [Platform.COVER, Platform.SWITCH]
type IottyConfigEntry = ConfigEntry[IottyConfigEntryData] type IottyConfigEntry = ConfigEntry[IottyConfigEntryData]

View File

@ -7,7 +7,8 @@ from datetime import timedelta
import logging import logging
from iottycloud.device import Device from iottycloud.device import Device
from iottycloud.verbs import RESULT, STATUS from iottycloud.shutter import Shutter
from iottycloud.verbs import OPEN_PERCENTAGE, RESULT, STATUS
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -104,5 +105,9 @@ class IottyDataUpdateCoordinator(DataUpdateCoordinator[IottyData]):
"Retrieved status: '%s' for device %s", status, device.device_id "Retrieved status: '%s' for device %s", status, device.device_id
) )
device.update_status(status) device.update_status(status)
if isinstance(device, Shutter) and isinstance(
percentage := json.get(OPEN_PERCENTAGE), int
):
device.update_percentage(percentage)
return IottyData(self._devices) return IottyData(self._devices)

View File

@ -0,0 +1,193 @@
"""Implement a iotty Shutter Device."""
from __future__ import annotations
import logging
from typing import Any
from iottycloud.device import Device
from iottycloud.shutter import Shutter, ShutterState
from iottycloud.verbs import SH_DEVICE_TYPE_UID
from homeassistant.components.cover import (
ATTR_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import IottyConfigEntry
from .api import IottyProxy
from .coordinator import IottyDataUpdateCoordinator
from .entity import IottyEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IottyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Activate the iotty Shutter component."""
_LOGGER.debug("Setup COVER entry id is %s", config_entry.entry_id)
coordinator = config_entry.runtime_data.coordinator
entities = [
IottyShutter(
coordinator=coordinator, iotty_cloud=coordinator.iotty, iotty_device=d
)
for d in coordinator.data.devices
if d.device_type == SH_DEVICE_TYPE_UID
if (isinstance(d, Shutter))
]
_LOGGER.debug("Found %d Shutters", len(entities))
async_add_entities(entities)
known_devices: set = config_entry.runtime_data.known_devices
for known_device in coordinator.data.devices:
if known_device.device_type == SH_DEVICE_TYPE_UID:
known_devices.add(known_device)
@callback
def async_update_data() -> None:
"""Handle updated data from the API endpoint."""
if not coordinator.last_update_success:
return
devices = coordinator.data.devices
entities = []
known_devices: set = config_entry.runtime_data.known_devices
# Add entities for devices which we've not yet seen
for device in devices:
if (
any(d.device_id == device.device_id for d in known_devices)
or device.device_type != SH_DEVICE_TYPE_UID
):
continue
iotty_entity = IottyShutter(
coordinator=coordinator,
iotty_cloud=coordinator.iotty,
iotty_device=Shutter(
device.device_id,
device.serial_number,
device.device_type,
device.device_name,
),
)
entities.extend([iotty_entity])
known_devices.add(device)
async_add_entities(entities)
# Add a subscriber to the coordinator to discover new devices
coordinator.async_add_listener(async_update_data)
class IottyShutter(IottyEntity, CoverEntity):
"""Haas entity class for iotty Shutter."""
_attr_device_class = CoverDeviceClass.SHUTTER
_iotty_device: Shutter
_attr_supported_features: CoverEntityFeature = CoverEntityFeature(0) | (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION
)
def __init__(
self,
coordinator: IottyDataUpdateCoordinator,
iotty_cloud: IottyProxy,
iotty_device: Shutter,
) -> None:
"""Initialize the Shutter device."""
super().__init__(coordinator, iotty_cloud, iotty_device)
@property
def current_cover_position(self) -> int | None:
"""Return the current position of the shutter.
None is unknown, 0 is closed, 100 is fully open.
"""
return self._iotty_device.percentage
@property
def is_closed(self) -> bool:
"""Return true if the Shutter is closed."""
_LOGGER.debug(
"Retrieve device status for %s ? %s : %s",
self._iotty_device.device_id,
self._iotty_device.status,
self._iotty_device.percentage,
)
return (
self._iotty_device.status == ShutterState.STATIONARY
and self._iotty_device.percentage == 0
)
@property
def is_opening(self) -> bool:
"""Return true if the Shutter is opening."""
return self._iotty_device.status == ShutterState.OPENING
@property
def is_closing(self) -> bool:
"""Return true if the Shutter is closing."""
return self._iotty_device.status == ShutterState.CLOSING
@property
def supported_features(self) -> CoverEntityFeature:
"""Flag supported features."""
return self._attr_supported_features
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self._iotty_cloud.command(
self._iotty_device.device_id, self._iotty_device.cmd_open()
)
await self.coordinator.async_request_refresh()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
await self._iotty_cloud.command(
self._iotty_device.device_id, self._iotty_device.cmd_close()
)
await self.coordinator.async_request_refresh()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
percentage = kwargs[ATTR_POSITION]
await self._iotty_cloud.command(
self._iotty_device.device_id,
self._iotty_device.cmd_move_to(),
{"open_percentage": percentage},
)
await self.coordinator.async_request_refresh()
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self._iotty_cloud.command(
self._iotty_device.device_id, self._iotty_device.cmd_stop()
)
await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
device: Device = next(
device
for device in self.coordinator.data.devices
if device.device_id == self._iotty_device.device_id
)
if isinstance(device, Shutter):
self._iotty_device = device
self.async_write_ha_state()

View File

@ -0,0 +1,49 @@
"""Base class for iotty entities."""
import logging
from iottycloud.lightswitch import Device
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api import IottyProxy
from .const import DOMAIN
from .coordinator import IottyDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
class IottyEntity(CoordinatorEntity[IottyDataUpdateCoordinator]):
"""Defines a base iotty entity."""
_attr_has_entity_name = True
_attr_name = None
_iotty_device_name: str
_iotty_cloud: IottyProxy
_iotty_device: Device
def __init__(
self,
coordinator: IottyDataUpdateCoordinator,
iotty_cloud: IottyProxy,
iotty_device: Device,
) -> None:
"""Initialize iotty entity."""
super().__init__(coordinator)
_LOGGER.debug(
"Creating new COVER (%s) %s",
iotty_device.device_type,
iotty_device.device_id,
)
self._iotty_cloud = iotty_cloud
self._attr_unique_id = iotty_device.device_id
self._iotty_device_name = iotty_device.name
self._iotty_device = iotty_device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, iotty_device.device_id)},
name=iotty_device.name,
manufacturer="iotty",
)

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, cast from typing import Any
from iottycloud.device import Device from iottycloud.device import Device
from iottycloud.lightswitch import LightSwitch from iottycloud.lightswitch import LightSwitch
@ -11,14 +11,12 @@ from iottycloud.verbs import LS_DEVICE_TYPE_UID
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import IottyConfigEntry from . import IottyConfigEntry
from .api import IottyProxy from .api import IottyProxy
from .const import DOMAIN
from .coordinator import IottyDataUpdateCoordinator from .coordinator import IottyDataUpdateCoordinator
from .entity import IottyEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -87,14 +85,10 @@ async def async_setup_entry(
coordinator.async_add_listener(async_update_data) coordinator.async_add_listener(async_update_data)
class IottyLightSwitch(SwitchEntity, CoordinatorEntity[IottyDataUpdateCoordinator]): class IottyLightSwitch(IottyEntity, SwitchEntity):
"""Haas entity class for iotty LightSwitch.""" """Haas entity class for iotty LightSwitch."""
_attr_has_entity_name = True
_attr_name = None
_attr_entity_category = None
_attr_device_class = SwitchDeviceClass.SWITCH _attr_device_class = SwitchDeviceClass.SWITCH
_iotty_cloud: IottyProxy
_iotty_device: LightSwitch _iotty_device: LightSwitch
def __init__( def __init__(
@ -104,26 +98,7 @@ class IottyLightSwitch(SwitchEntity, CoordinatorEntity[IottyDataUpdateCoordinato
iotty_device: LightSwitch, iotty_device: LightSwitch,
) -> None: ) -> None:
"""Initialize the LightSwitch device.""" """Initialize the LightSwitch device."""
super().__init__(coordinator=coordinator) super().__init__(coordinator, iotty_cloud, iotty_device)
_LOGGER.debug(
"Creating new SWITCH (%s) %s",
iotty_device.device_type,
iotty_device.device_id,
)
self._iotty_cloud = iotty_cloud
self._iotty_device = iotty_device
self._attr_unique_id = iotty_device.device_id
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
identifiers={(DOMAIN, cast(str, self._attr_unique_id))},
name=self._iotty_device.name,
manufacturer="iotty",
)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View File

@ -6,7 +6,18 @@ from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientSession from aiohttp import ClientSession
from iottycloud.device import Device from iottycloud.device import Device
from iottycloud.lightswitch import LightSwitch from iottycloud.lightswitch import LightSwitch
from iottycloud.verbs import LS_DEVICE_TYPE_UID, RESULT, STATUS, STATUS_OFF, STATUS_ON from iottycloud.shutter import Shutter
from iottycloud.verbs import (
LS_DEVICE_TYPE_UID,
OPEN_PERCENTAGE,
RESULT,
SH_DEVICE_TYPE_UID,
STATUS,
STATUS_OFF,
STATUS_ON,
STATUS_OPENING,
STATUS_STATIONATRY,
)
import pytest import pytest
from homeassistant import setup from homeassistant import setup
@ -48,6 +59,20 @@ test_ls_one_added = [
ls_2, ls_2,
] ]
sh_0 = Shutter("TestSH", "TEST_SERIAL_SH_0", SH_DEVICE_TYPE_UID, "[TEST] Shutter 0")
sh_1 = Shutter("TestSH1", "TEST_SERIAL_SH_1", SH_DEVICE_TYPE_UID, "[TEST] Shutter 1")
sh_2 = Shutter("TestSH2", "TEST_SERIAL_SH_2", SH_DEVICE_TYPE_UID, "[TEST] Shutter 2")
test_sh = [sh_0, sh_1]
test_sh_one_removed = [sh_0]
test_sh_one_added = [
sh_0,
sh_1,
sh_2,
]
@pytest.fixture @pytest.fixture
async def local_oauth_impl(hass: HomeAssistant): async def local_oauth_impl(hass: HomeAssistant):
@ -142,7 +167,7 @@ def mock_get_devices_nodevices() -> Generator[AsyncMock]:
@pytest.fixture @pytest.fixture
def mock_get_devices_twolightswitches() -> Generator[AsyncMock]: def mock_get_devices_twolightswitches() -> Generator[AsyncMock]:
"""Mock for get_devices, returning two objects.""" """Mock for get_devices, returning two switches."""
with patch( with patch(
"iottycloud.cloudapi.CloudApi.get_devices", return_value=test_ls "iottycloud.cloudapi.CloudApi.get_devices", return_value=test_ls
@ -150,6 +175,16 @@ def mock_get_devices_twolightswitches() -> Generator[AsyncMock]:
yield mock_fn yield mock_fn
@pytest.fixture
def mock_get_devices_twoshutters() -> Generator[AsyncMock]:
"""Mock for get_devices, returning two shutters."""
with patch(
"iottycloud.cloudapi.CloudApi.get_devices", return_value=test_sh
) as mock_fn:
yield mock_fn
@pytest.fixture @pytest.fixture
def mock_command_fn() -> Generator[AsyncMock]: def mock_command_fn() -> Generator[AsyncMock]:
"""Mock for command.""" """Mock for command."""
@ -169,6 +204,39 @@ def mock_get_status_filled_off() -> Generator[AsyncMock]:
yield mock_fn yield mock_fn
@pytest.fixture
def mock_get_status_filled_stationary_100() -> Generator[AsyncMock]:
"""Mock setting up a get_status."""
retval = {RESULT: {STATUS: STATUS_STATIONATRY, OPEN_PERCENTAGE: 100}}
with patch(
"iottycloud.cloudapi.CloudApi.get_status", return_value=retval
) as mock_fn:
yield mock_fn
@pytest.fixture
def mock_get_status_filled_stationary_0() -> Generator[AsyncMock]:
"""Mock setting up a get_status."""
retval = {RESULT: {STATUS: STATUS_STATIONATRY, OPEN_PERCENTAGE: 0}}
with patch(
"iottycloud.cloudapi.CloudApi.get_status", return_value=retval
) as mock_fn:
yield mock_fn
@pytest.fixture
def mock_get_status_filled_opening_50() -> Generator[AsyncMock]:
"""Mock setting up a get_status."""
retval = {RESULT: {STATUS: STATUS_OPENING, OPEN_PERCENTAGE: 50}}
with patch(
"iottycloud.cloudapi.CloudApi.get_status", return_value=retval
) as mock_fn:
yield mock_fn
@pytest.fixture @pytest.fixture
def mock_get_status_filled() -> Generator[AsyncMock]: def mock_get_status_filled() -> Generator[AsyncMock]:
"""Mock setting up a get_status.""" """Mock setting up a get_status."""

View File

@ -0,0 +1,238 @@
"""Unit tests the Hass COVER component."""
from aiohttp import ClientSession
from freezegun.api import FrozenDateTimeFactory
from iottycloud.verbs import (
OPEN_PERCENTAGE,
RESULT,
STATUS,
STATUS_CLOSING,
STATUS_OPENING,
STATUS_STATIONATRY,
)
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN as COVER_DOMAIN,
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
SERVICE_SET_COVER_POSITION,
SERVICE_STOP_COVER,
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
)
from homeassistant.components.iotty.const import DOMAIN
from homeassistant.components.iotty.coordinator import UPDATE_INTERVAL
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from .conftest import test_sh_one_added
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_open_ok(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
local_oauth_impl: ClientSession,
mock_get_devices_twoshutters,
mock_get_status_filled_stationary_0,
mock_command_fn,
) -> None:
"""Issue an open command."""
entity_id = "cover.test_shutter_0_test_serial_sh_0"
mock_config_entry.add_to_hass(hass)
config_entry_oauth2_flow.async_register_implementation(
hass, DOMAIN, local_oauth_impl
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert (state := hass.states.get(entity_id))
assert state.state == STATE_CLOSED
mock_get_status_filled_stationary_0.return_value = {
RESULT: {STATUS: STATUS_OPENING, OPEN_PERCENTAGE: 10}
}
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
mock_command_fn.assert_called_once()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OPENING
async def test_close_ok(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
local_oauth_impl: ClientSession,
mock_get_devices_twoshutters,
mock_get_status_filled_stationary_100,
mock_command_fn,
) -> None:
"""Issue a close command."""
entity_id = "cover.test_shutter_0_test_serial_sh_0"
mock_config_entry.add_to_hass(hass)
config_entry_oauth2_flow.async_register_implementation(
hass, DOMAIN, local_oauth_impl
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OPEN
mock_get_status_filled_stationary_100.return_value = {
RESULT: {STATUS: STATUS_CLOSING, OPEN_PERCENTAGE: 90}
}
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_CLOSE_COVER,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
mock_command_fn.assert_called_once()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_CLOSING
async def test_stop_ok(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
local_oauth_impl: ClientSession,
mock_get_devices_twoshutters,
mock_get_status_filled_opening_50,
mock_command_fn,
) -> None:
"""Issue a stop command."""
entity_id = "cover.test_shutter_0_test_serial_sh_0"
mock_config_entry.add_to_hass(hass)
config_entry_oauth2_flow.async_register_implementation(
hass, DOMAIN, local_oauth_impl
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OPENING
mock_get_status_filled_opening_50.return_value = {
RESULT: {STATUS: STATUS_STATIONATRY, OPEN_PERCENTAGE: 60}
}
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_STOP_COVER,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
mock_command_fn.assert_called_once()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OPEN
async def test_set_position_ok(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
local_oauth_impl: ClientSession,
mock_get_devices_twoshutters,
mock_get_status_filled_stationary_0,
mock_command_fn,
) -> None:
"""Issue a set position command."""
entity_id = "cover.test_shutter_0_test_serial_sh_0"
mock_config_entry.add_to_hass(hass)
config_entry_oauth2_flow.async_register_implementation(
hass, DOMAIN, local_oauth_impl
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert (state := hass.states.get(entity_id))
assert state.state == STATE_CLOSED
mock_get_status_filled_stationary_0.return_value = {
RESULT: {STATUS: STATUS_OPENING, OPEN_PERCENTAGE: 50}
}
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 10},
blocking=True,
)
await hass.async_block_till_done()
mock_command_fn.assert_called_once()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OPENING
async def test_devices_insertion_ok(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
local_oauth_impl: ClientSession,
mock_get_devices_twoshutters,
mock_get_status_filled_stationary_0,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test iotty cover insertion."""
mock_config_entry.add_to_hass(hass)
config_entry_oauth2_flow.async_register_implementation(
hass, DOMAIN, local_oauth_impl
)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
# Should have two devices
assert hass.states.async_entity_ids_count() == 2
assert hass.states.async_entity_ids() == [
"cover.test_shutter_0_test_serial_sh_0",
"cover.test_shutter_1_test_serial_sh_1",
]
mock_get_devices_twoshutters.return_value = test_sh_one_added
freezer.tick(UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Should have three devices
assert hass.states.async_entity_ids_count() == 3
assert hass.states.async_entity_ids() == [
"cover.test_shutter_0_test_serial_sh_0",
"cover.test_shutter_1_test_serial_sh_1",
"cover.test_shutter_2_test_serial_sh_2",
]