Skip to content

Commit

Permalink
Merge pull request #27 from planetmarshall/force-hot-water
Browse files Browse the repository at this point in the history
Add force hot water switch
  • Loading branch information
planetmarshall authored May 6, 2024
2 parents cb79529 + c034e19 commit c635d12
Show file tree
Hide file tree
Showing 13 changed files with 148 additions and 46 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
1 change: 1 addition & 0 deletions custom_components/ha_ecodan/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

LOGGER: Logger = getLogger(__package__)

MANUFACTURER = "Mitsubishi Electric"
NAME = "Ecodan"
DOMAIN = "ha_ecodan"
VERSION = "0.1.1"
Expand Down
4 changes: 2 additions & 2 deletions custom_components/ha_ecodan/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -21,5 +21,5 @@ def __init__(self, coordinator: EcodanDataUpdateCoordinator) -> None:
identifiers={(DOMAIN, coordinator.device.id)},
name=NAME,
model=VERSION,
manufacturer=NAME,
manufacturer=MANUFACTURER,
)
6 changes: 3 additions & 3 deletions custom_components/ha_ecodan/pyecodan/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
18 changes: 16 additions & 2 deletions custom_components/ha_ecodan/pyecodan/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class EffectiveFlags(IntFlag):
Update = 0x0
Power = 0x1
OperationModeZone1 = 0x8
ForceHotWater = 0x10000


class DeviceStateKeys:
Expand All @@ -19,6 +20,7 @@ class DeviceStateKeys:
OutdoorTemperature = "OutdoorTemperature"
HotWaterTemperature = "TankWaterTemperature"
OperationModeZone1 = "OperationModeZone1"
ForcedHotWaterMode = "ForcedHotWaterMode"
Power = "Power"


Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
13 changes: 6 additions & 7 deletions custom_components/ha_ecodan/pyecodan/examples/list_devices.py
Original file line number Diff line number Diff line change
@@ -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())
49 changes: 40 additions & 9 deletions custom_components/ha_ecodan/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,82 @@

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,
)
for entity_description in ENTITY_DESCRIPTIONS
)


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()
3 changes: 2 additions & 1 deletion test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

from unittest.mock import Mock
from unittest.mock import Mock, AsyncMock

from data.melcloud import MelCloudData

Expand All @@ -13,6 +13,7 @@ def _coordinator(_data: dict=None):

mock = Mock()
mock.data = _data
mock.async_request_refresh = AsyncMock()
return mock

return _coordinator
Expand Down
7 changes: 5 additions & 2 deletions test/data/melcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,6 @@
}

_example_request = {
"EffectiveFlags": 281475043819552,
"OperationModeZone1": 1,
"DeviceID": 67204455,
}

Expand Down Expand Up @@ -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
26 changes: 26 additions & 0 deletions test/pyecodan/test_ecodan_hot_water.py
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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))
46 changes: 46 additions & 0 deletions test/test_hot_water.py
Original file line number Diff line number Diff line change
@@ -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)
19 changes: 0 additions & 19 deletions test/test_hot_water_temp_sensor.py

This file was deleted.

0 comments on commit c635d12

Please sign in to comment.