diff --git a/README.md b/README.md index 6975684..4b90f98 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ ha-ecodan ========= +![Lint and test workflow](https://github.com/planetmarshall/ha-ecodan/actions/workflows/lint_and_test.yml/badge.svg) A [Home Assistant](https://www.home-assistant.io/) integration for the [Mitsubishi Ecodan](https://les.mitsubishielectric.co.uk/products/residential-heating/outdoor) diff --git a/custom_components/ha_ecodan/const.py b/custom_components/ha_ecodan/const.py index 836fbae..a782a0c 100644 --- a/custom_components/ha_ecodan/const.py +++ b/custom_components/ha_ecodan/const.py @@ -4,6 +4,7 @@ LOGGER: Logger = getLogger(__package__) +MANUFACTURER = "Mitsubishi Electric" NAME = "Ecodan" DOMAIN = "ha_ecodan" VERSION = "0.1.1" diff --git a/custom_components/ha_ecodan/entity.py b/custom_components/ha_ecodan/entity.py index ccdca5d..165cceb 100644 --- a/custom_components/ha_ecodan/entity.py +++ b/custom_components/ha_ecodan/entity.py @@ -3,7 +3,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, NAME, VERSION +from .const import ATTRIBUTION, DOMAIN, NAME, VERSION, MANUFACTURER from .coordinator import EcodanDataUpdateCoordinator @@ -21,5 +21,5 @@ def __init__(self, coordinator: EcodanDataUpdateCoordinator) -> None: identifiers={(DOMAIN, coordinator.device.id)}, name=NAME, model=VERSION, - manufacturer=NAME, + manufacturer=MANUFACTURER, ) diff --git a/custom_components/ha_ecodan/pyecodan/client.py b/custom_components/ha_ecodan/pyecodan/client.py index ec31ef7..aa92956 100644 --- a/custom_components/ha_ecodan/pyecodan/client.py +++ b/custom_components/ha_ecodan/pyecodan/client.py @@ -27,13 +27,13 @@ def __init__( self._context_key = None self._session = session or ClientSession() - async def device_request(self, endpoint: str, state: dict): - """Make a request of the API.""" + async def device_request(self, state: dict): + """Make a SetAtw request of the API.""" if self._context_key is None: await self.login() auth_header = {"X-MitsContextKey": self._context_key} - url = f"{Client.base_url}/Device/{endpoint}" + url = f"{Client.base_url}/Device/SetAtw" async with self._session.post(url, headers=auth_header, json=state) as response: return await response.json() diff --git a/custom_components/ha_ecodan/pyecodan/device.py b/custom_components/ha_ecodan/pyecodan/device.py index 848d9c0..9647c86 100644 --- a/custom_components/ha_ecodan/pyecodan/device.py +++ b/custom_components/ha_ecodan/pyecodan/device.py @@ -9,6 +9,7 @@ class EffectiveFlags(IntFlag): Update = 0x0 Power = 0x1 OperationModeZone1 = 0x8 + ForceHotWater = 0x10000 class DeviceStateKeys: @@ -19,6 +20,7 @@ class DeviceStateKeys: OutdoorTemperature = "OutdoorTemperature" HotWaterTemperature = "TankWaterTemperature" OperationModeZone1 = "OperationModeZone1" + ForcedHotWaterMode = "ForcedHotWaterMode" Power = "Power" @@ -53,6 +55,7 @@ def __init__(self, device_state: dict): DeviceStateKeys.OutdoorTemperature, DeviceStateKeys.HotWaterTemperature, DeviceStateKeys.OperationModeZone1, + DeviceStateKeys.ForcedHotWaterMode, ): self._state[field] = internal_device_state[field] @@ -97,14 +100,18 @@ def operation_mode(self) -> OperationMode: """The heating operation mode.""" return OperationMode(self._state[DeviceStateKeys.OperationModeZone1]) + @property + def forced_hot_water(self) -> bool: + """Get if hot water is currently being forced.""" + return self._state[DeviceStateKeys.ForcedHotWaterMode] + async def _request(self, effective_flags: EffectiveFlags, **kwargs) -> dict: state = { - # DevicePropertyKeys.BuildingID: self.building_id, DevicePropertyKeys.DeviceID: self.id, DevicePropertyKeys.EffectiveFlags: effective_flags, } state.update(kwargs) - return await self._client.device_request("SetAtw", state) + return await self._client.device_request(state) async def get_state(self) -> dict: """Request a state update and return as a dictionary.""" @@ -139,3 +146,10 @@ async def power_off(self) -> None: response_state = await self._request(EffectiveFlags.Power, Power=False) if response_state[DeviceStateKeys.Power]: raise DeviceCommunicationError("Power could not be set") + + async def force_hot_water(self, force: bool) -> None: + """Force hot water mode.""" + response_state = await self._request(EffectiveFlags.ForceHotWater, ForcedHotWaterMode=force) + if not response_state[DeviceStateKeys.ForcedHotWaterMode] == force: + raise DeviceCommunicationError(f"Could not set forced hot water mode to : {force}") + self._check_response(response_state) diff --git a/custom_components/ha_ecodan/pyecodan/examples/list_devices.py b/custom_components/ha_ecodan/pyecodan/examples/list_devices.py index b002060..2e843c5 100644 --- a/custom_components/ha_ecodan/pyecodan/examples/list_devices.py +++ b/custom_components/ha_ecodan/pyecodan/examples/list_devices.py @@ -1,21 +1,20 @@ import asyncio -import pyecodan -from pyecodan.device import OperationMode +from custom_components.ha_ecodan.pyecodan.client import Client +from custom_components.ha_ecodan.pyecodan.device import OperationMode async def main(): - async with pyecodan.Client() as client: + async with Client() as client: devices = await client.list_devices() device = devices["Naze View"] device = await client.get_device(device["id"]) - await device.set_operation_mode(OperationMode.Room) + await device.force_hot_water(True) print(device.name) print(device.id) - print(device.operation_mode) + print(device.forced_hot_water) if __name__ == "__main__": - loop = asyncio.new_event_loop() - loop.run_until_complete(main()) + asyncio.run(main()) diff --git a/custom_components/ha_ecodan/switch.py b/custom_components/ha_ecodan/switch.py index cd214ff..9c9b222 100644 --- a/custom_components/ha_ecodan/switch.py +++ b/custom_components/ha_ecodan/switch.py @@ -2,21 +2,22 @@ from __future__ import annotations +from dataclasses import dataclass +from collections.abc import Callable + from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from .const import DOMAIN from .coordinator import EcodanDataUpdateCoordinator from .entity import EcodanEntity -from .pyecodan.device import DeviceStateKeys - -ENTITY_DESCRIPTIONS = (SwitchEntityDescription(key="ha_ecodan", name="Power Switch", icon="mdi:power"),) +from .pyecodan.device import Device, DeviceStateKeys async def async_setup_entry(hass, entry, async_add_devices): """Set up the sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] async_add_devices( - EcodanPowerSwitch( + EcodanSwitch( coordinator=coordinator, entity_description=entity_description, ) @@ -24,29 +25,59 @@ async def async_setup_entry(hass, entry, async_add_devices): ) -class EcodanPowerSwitch(EcodanEntity, SwitchEntity): +@dataclass(kw_only=True) +class EcodanSwitchEntityDescription(SwitchEntityDescription): + """Custom description class for Ecodan Switch Entities.""" + + state_key: str + turn_on_fn: Callable[[], Device] + turn_off_fn: Callable[[], Device] + + +ENTITY_DESCRIPTIONS = [ + EcodanSwitchEntityDescription( + key="ha_ecodan", + name="Power Switch", + icon="mdi:power", + state_key=DeviceStateKeys.Power, + turn_on_fn=Device.power_on, + turn_off_fn=Device.power_off, + ), + EcodanSwitchEntityDescription( + key="ha_ecodan", + name="Force Hot Water", + icon="mdi:thermometer-water", + state_key=DeviceStateKeys.ForcedHotWaterMode, + turn_on_fn=lambda device: device.force_hot_water(True), + turn_off_fn=lambda device: device.force_hot_water(False), + ), +] + + +class EcodanSwitch(EcodanEntity, SwitchEntity): """A Switch Entity for Ecodan Heatpumps.""" def __init__( self, coordinator: EcodanDataUpdateCoordinator, - entity_description: SwitchEntityDescription, + entity_description: EcodanSwitchEntityDescription, ) -> None: """Initialize the switch class.""" super().__init__(coordinator) self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.device.id}_{entity_description.state_key}".lower() @property def is_on(self) -> bool: """Return true if the switch is on.""" - return self.coordinator.data.get(DeviceStateKeys.Power) + return self.coordinator.data.get(self.entity_description.state_key) async def async_turn_on(self, **_: any) -> None: """Turn on the switch.""" - await self.coordinator.device.power_on() + await self.entity_description.turn_on_fn(self.coordinator.device) await self.coordinator.async_request_refresh() async def async_turn_off(self, **_: any) -> None: """Turn off the switch.""" - await self.coordinator.device.power_off() + await self.entity_description.turn_off_fn(self.coordinator.device) await self.coordinator.async_request_refresh() diff --git a/test/conftest.py b/test/conftest.py index ca8fc00..ad7c41c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,6 @@ import pytest -from unittest.mock import Mock +from unittest.mock import Mock, AsyncMock from data.melcloud import MelCloudData @@ -13,6 +13,7 @@ def _coordinator(_data: dict=None): mock = Mock() mock.data = _data + mock.async_request_refresh = AsyncMock() return mock return _coordinator diff --git a/test/data/melcloud.py b/test/data/melcloud.py index ed6e492..da960a0 100644 --- a/test/data/melcloud.py +++ b/test/data/melcloud.py @@ -335,8 +335,6 @@ } _example_request = { - "EffectiveFlags": 281475043819552, - "OperationModeZone1": 1, "DeviceID": 67204455, } @@ -464,3 +462,8 @@ def request_with(self, **kwargs) -> dict: request = _example_request.copy() request.update(kwargs) return request + + def response_with(self, **kwargs) -> dict: + response = _example_response.copy() + response.update(kwargs) + return response diff --git a/test/pyecodan/test_ecodan_hot_water.py b/test/pyecodan/test_ecodan_hot_water.py new file mode 100644 index 0000000..985f28b --- /dev/null +++ b/test/pyecodan/test_ecodan_hot_water.py @@ -0,0 +1,26 @@ +import pytest + +from custom_components.ha_ecodan.pyecodan.device import Device + +from unittest.mock import Mock, AsyncMock + + +@pytest.mark.parametrize("force_hot_water", [True, False]) +def test_hot_water_mode_from_state(melcloud, force_hot_water): + client = Mock() + device = Device(client, melcloud.device_state_with(ForcedHotWaterMode=force_hot_water)) + + assert device.forced_hot_water is force_hot_water + + +@pytest.mark.asyncio +@pytest.mark.parametrize("force_hot_water", [True, False]) +async def test_hot_water_mode_from_state(melcloud, force_hot_water): + client = Mock() + client.device_request = AsyncMock(return_value=melcloud.response_with(ForcedHotWaterMode=force_hot_water)) + + device = Device(client, melcloud.device_state) + await device.force_hot_water(force_hot_water) + + client.device_request.assert_awaited_with( + melcloud.request_with(EffectiveFlags=65536, ForcedHotWaterMode=force_hot_water)) diff --git a/test/pyecodan/test_operation_modes.py b/test/pyecodan/test_ecodan_operation_modes.py similarity index 98% rename from test/pyecodan/test_operation_modes.py rename to test/pyecodan/test_ecodan_operation_modes.py index 6336feb..16dedf6 100644 --- a/test/pyecodan/test_operation_modes.py +++ b/test/pyecodan/test_ecodan_operation_modes.py @@ -32,5 +32,4 @@ async def test_set_operation_mode(melcloud, operation_mode_index, operation_mode await device.set_operation_mode(operation_mode) client.device_request.assert_called_with( - "SetAtw", melcloud.request_with(EffectiveFlags=8, OperationModeZone1=operation_mode_index)) diff --git a/test/test_hot_water.py b/test/test_hot_water.py new file mode 100644 index 0000000..95cffc6 --- /dev/null +++ b/test/test_hot_water.py @@ -0,0 +1,46 @@ +from unittest.mock import AsyncMock + +import pytest +from homeassistant.components.sensor import SensorDeviceClass + +from custom_components.ha_ecodan.sensor import EcodanSensor, ENTITY_DESCRIPTIONS as SENSOR_ENTITY_DESCRIPTIONS +from custom_components.ha_ecodan.switch import EcodanSwitch, ENTITY_DESCRIPTIONS as SWITCH_ENTITY_DESCRIPTIONS + + +def test_hot_water_sensor_properties(coordinator): + + sensor = EcodanSensor(coordinator(), SENSOR_ENTITY_DESCRIPTIONS[2]) + + assert sensor.device_class == SensorDeviceClass.TEMPERATURE + assert sensor.name == "Hot Water Temperature" + + +def test_hot_water_sensor_value_from_coordinator(coordinator): + data = {"TankWaterTemperature": 47} + + sensor = EcodanSensor(coordinator(data), SENSOR_ENTITY_DESCRIPTIONS[2]) + + assert sensor.native_value == 47 + + +@pytest.mark.asyncio +async def test_force_hot_water_switch_on(coordinator): + data = {"ForcedHotWaterMode": False} + obj = coordinator(data) + obj.device.force_hot_water = AsyncMock() + + switch = EcodanSwitch(obj, SWITCH_ENTITY_DESCRIPTIONS[1]) + await switch.async_turn_on() + + obj.device.force_hot_water.assert_awaited_with(True) + +@pytest.mark.asyncio +async def test_force_hot_water_switch_off(coordinator): + data = {"ForcedHotWaterMode": True} + obj = coordinator(data) + obj.device.force_hot_water = AsyncMock() + + switch = EcodanSwitch(obj, SWITCH_ENTITY_DESCRIPTIONS[1]) + await switch.async_turn_off() + + obj.device.force_hot_water.assert_awaited_with(False) diff --git a/test/test_hot_water_temp_sensor.py b/test/test_hot_water_temp_sensor.py deleted file mode 100644 index 71ca9e9..0000000 --- a/test/test_hot_water_temp_sensor.py +++ /dev/null @@ -1,19 +0,0 @@ -from homeassistant.components.sensor import SensorDeviceClass - -from custom_components.ha_ecodan.sensor import EcodanSensor, ENTITY_DESCRIPTIONS - - -def test_hot_water_sensor_properties(coordinator): - - sensor = EcodanSensor(coordinator(), ENTITY_DESCRIPTIONS[2]) - - assert sensor.device_class == SensorDeviceClass.TEMPERATURE - assert sensor.name == "Hot Water Temperature" - - -def test_hot_water_sensor_value_from_coordinator(coordinator): - data = {"TankWaterTemperature": 47} - - sensor = EcodanSensor(coordinator(data), ENTITY_DESCRIPTIONS[2]) - - assert sensor.native_value == 47