From 53e9a2451ee7afa35c54f521f06644487bb4651d Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 21 Jan 2022 10:44:56 +0100 Subject: [PATCH] Add switch platform to HomeWizard Energy (#64084) Co-authored-by: Franck Nijhof --- .../components/homewizard/__init__.py | 8 +- homeassistant/components/homewizard/const.py | 4 +- .../components/homewizard/coordinator.py | 6 +- .../components/homewizard/manifest.json | 2 +- homeassistant/components/homewizard/sensor.py | 6 +- homeassistant/components/homewizard/switch.py | 129 ++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homewizard/test_switch.py | 293 ++++++++++++++++++ 9 files changed, 434 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/homewizard/switch.py create mode 100644 tests/components/homewizard/test_switch.py diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index f563902c6e8..bca041c6a27 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import UpdateFailed -from .const import COORDINATOR, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator _LOGGER = logging.getLogger(__name__) @@ -36,9 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Finalize hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: coordinator, - } + hass.data[DOMAIN][entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -53,6 +51,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: config_data = hass.data[DOMAIN].pop(entry.entry_id) - await config_data[COORDINATOR].api.close() + await config_data.api.close() return unload_ok diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index ce66f7ed2e8..9a6c465532f 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -7,11 +7,11 @@ from typing import TypedDict # Set up. from aiohwenergy.device import Device +from homeassistant.const import Platform from homeassistant.helpers.typing import StateType DOMAIN = "homewizard" -COORDINATOR = "coordinator" -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] # Platform config. CONF_SERIAL = "serial" diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index e6be6e871a8..a2612d07464 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -15,12 +15,10 @@ from .const import DOMAIN, UPDATE_INTERVAL, DeviceResponseEntry _LOGGER = logging.getLogger(__name__) -class HWEnergyDeviceUpdateCoordinator( - DataUpdateCoordinator[aiohwenergy.HomeWizardEnergy] -): +class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]): """Gather data for the energy device.""" - api: aiohwenergy + api: aiohwenergy.HomeWizardEnergy def __init__( self, diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index e1b6db1911f..641bfca520e 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -5,7 +5,7 @@ "codeowners": ["@DCSBL"], "dependencies": [], "requirements": [ - "aiohwenergy==0.6.0" + "aiohwenergy==0.7.0" ], "zeroconf": ["_hwenergy._tcp.local."], "config_flow": true, diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 148d74436b6..c2a11386cf4 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import COORDINATOR, DOMAIN, DeviceResponseEntry +from .const import DOMAIN, DeviceResponseEntry from .coordinator import HWEnergyDeviceUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -130,9 +130,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize sensors.""" - coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [] if coordinator.api.data is not None: diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py new file mode 100644 index 00000000000..7860370baa7 --- /dev/null +++ b/homeassistant/components/homewizard/switch.py @@ -0,0 +1,129 @@ +"""Creates Homewizard Energy switch entities.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HWEnergyDeviceUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches.""" + coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + if coordinator.api.state: + async_add_entities( + [ + HWEnergyMainSwitchEntity(coordinator, entry), + HWEnergySwitchLockEntity(coordinator, entry), + ] + ) + + +class HWEnergySwitchEntity(CoordinatorEntity, SwitchEntity): + """Representation switchable entity.""" + + coordinator: HWEnergyDeviceUpdateCoordinator + + def __init__( + self, + coordinator: HWEnergyDeviceUpdateCoordinator, + entry: ConfigEntry, + key: str, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self._attr_unique_id = f"{entry.unique_id}_{key}" + self._attr_device_info = { + "name": entry.title, + "manufacturer": "HomeWizard", + "sw_version": coordinator.data["device"].firmware_version, + "model": coordinator.data["device"].product_type, + "identifiers": {(DOMAIN, coordinator.data["device"].serial)}, + } + + +class HWEnergyMainSwitchEntity(HWEnergySwitchEntity): + """Representation of the main power switch.""" + + _attr_device_class = SwitchDeviceClass.OUTLET + + def __init__( + self, coordinator: HWEnergyDeviceUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, entry, "power_on") + + # Config attributes + self._attr_name = f"{entry.title} Switch" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.api.state.set(power_on=True) + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.api.state.set(power_on=False) + await self.coordinator.async_refresh() + + @property + def available(self) -> bool: + """ + Return availability of power_on. + + This switch becomes unavailable when switch_lock is enabled. + """ + return super().available and not self.coordinator.api.state.switch_lock + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return bool(self.coordinator.api.state.power_on) + + +class HWEnergySwitchLockEntity(HWEnergySwitchEntity): + """ + Representation of the switch-lock configuration. + + Switch-lock is a feature that forces the relay in 'on' state. + It disables any method that can turn of the relay. + """ + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, coordinator: HWEnergyDeviceUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, entry, "switch_lock") + + # Config attributes + self._attr_name = f"{entry.title} Switch Lock" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch-lock on.""" + await self.coordinator.api.state.set(switch_lock=True) + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch-lock off.""" + await self.coordinator.api.state.set(switch_lock=False) + await self.coordinator.async_refresh() + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return bool(self.coordinator.api.state.switch_lock) diff --git a/requirements_all.txt b/requirements_all.txt index ff511e76fd7..6981505c3c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -194,7 +194,7 @@ aiohttp_cors==0.7.0 aiohue==3.0.11 # homeassistant.components.homewizard -aiohwenergy==0.6.0 +aiohwenergy==0.7.0 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84eb03bd6c9..62190633794 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -144,7 +144,7 @@ aiohttp_cors==0.7.0 aiohue==3.0.11 # homeassistant.components.homewizard -aiohwenergy==0.6.0 +aiohwenergy==0.7.0 # homeassistant.components.apache_kafka aiokafka==0.6.0 diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py new file mode 100644 index 00000000000..f3792a9d75b --- /dev/null +++ b/tests/components/homewizard/test_switch.py @@ -0,0 +1,293 @@ +"""Test the update coordinator for HomeWizard.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components import switch +from homeassistant.components.switch import DEVICE_CLASS_OUTLET, DEVICE_CLASS_SWITCH +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.helpers import entity_registry as er + +from .generator import get_mock_device + + +async def test_switch_entity_not_loaded_when_not_available( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads smr version.""" + + api = get_mock_device() + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state_power_on = hass.states.get("sensor.product_name_aabbccddeeff_switch") + state_switch_lock = hass.states.get("sensor.product_name_aabbccddeeff_switch_lock") + + assert state_power_on is None + assert state_switch_lock is None + + +async def test_switch_loads_entities(hass, mock_config_entry_data, mock_config_entry): + """Test entity loads smr version.""" + + api = get_mock_device() + api.state = AsyncMock() + + api.state.power_on = False + api.state.switch_lock = False + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state_power_on = hass.states.get("switch.product_name_aabbccddeeff_switch") + entry_power_on = entity_registry.async_get( + "switch.product_name_aabbccddeeff_switch" + ) + assert state_power_on + assert entry_power_on + assert entry_power_on.unique_id == "aabbccddeeff_power_on" + assert not entry_power_on.disabled + assert state_power_on.state == STATE_OFF + assert ( + state_power_on.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Switch" + ) + assert state_power_on.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_OUTLET + assert ATTR_ICON not in state_power_on.attributes + + state_switch_lock = hass.states.get("switch.product_name_aabbccddeeff_switch_lock") + entry_switch_lock = entity_registry.async_get( + "switch.product_name_aabbccddeeff_switch_lock" + ) + + assert state_switch_lock + assert entry_switch_lock + assert entry_switch_lock.unique_id == "aabbccddeeff_switch_lock" + assert not entry_switch_lock.disabled + assert state_switch_lock.state == STATE_OFF + assert ( + state_switch_lock.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Switch Lock" + ) + assert state_switch_lock.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SWITCH + assert ATTR_ICON not in state_switch_lock.attributes + + +async def test_switch_power_on_off(hass, mock_config_entry_data, mock_config_entry): + """Test entity turns switch on and off.""" + + api = get_mock_device() + api.state = AsyncMock() + api.state.power_on = False + api.state.switch_lock = False + + def set_power_on(power_on): + api.state.power_on = power_on + + api.state.set = AsyncMock(side_effect=set_power_on) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state + == STATE_OFF + ) + + # Turn power_on on + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "switch.product_name_aabbccddeeff_switch"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(api.state.set.mock_calls) == 1 + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state == STATE_ON + ) + + # Turn power_on off + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "switch.product_name_aabbccddeeff_switch"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state + == STATE_OFF + ) + assert len(api.state.set.mock_calls) == 2 + + +async def test_switch_lock_power_on_off( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity turns switch on and off.""" + + api = get_mock_device() + api.state = AsyncMock() + api.state.power_on = False + api.state.switch_lock = False + + def set_switch_lock(switch_lock): + api.state.switch_lock = switch_lock + + api.state.set = AsyncMock(side_effect=set_switch_lock) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_OFF + ) + + # Turn power_on on + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(api.state.set.mock_calls) == 1 + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_ON + ) + + # Turn power_on off + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_OFF + ) + assert len(api.state.set.mock_calls) == 2 + + +async def test_switch_lock_sets_power_on_unavailable( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity turns switch on and off.""" + + api = get_mock_device() + api.state = AsyncMock() + api.state.power_on = True + api.state.switch_lock = False + + def set_switch_lock(switch_lock): + api.state.switch_lock = switch_lock + + api.state.set = AsyncMock(side_effect=set_switch_lock) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state == STATE_ON + ) + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_OFF + ) + + # Turn power_on on + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(api.state.set.mock_calls) == 1 + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_ON + ) + + # Turn power_on off + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state == STATE_ON + ) + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_OFF + ) + assert len(api.state.set.mock_calls) == 2