diff --git a/.github/workflows/lint.yml b/.github/workflows/lint_and_test.yml similarity index 61% rename from .github/workflows/lint.yml rename to .github/workflows/lint_and_test.yml index 25bf6cc..f6512e4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint_and_test.yml @@ -1,4 +1,4 @@ -name: "Lint" +name: "Lint and Test" on: push: @@ -9,8 +9,8 @@ on: - "main" jobs: - ruff: - name: "Ruff" + lint_and_test: + name: Lint and run tests runs-on: "ubuntu-latest" steps: - name: "Checkout the repository" @@ -25,5 +25,11 @@ jobs: - name: "Install requirements" run: python3 -m pip install -r requirements.txt - - name: "Run" - run: python3 -m ruff check . + - name: Lint Check + run: ruff check custom_components + + - name: Format check + run: ruff format --check custom_components + + - name: Run tests + run: python -m pytest -v test diff --git a/.ruff.toml b/.ruff.toml index 9c95ca0..bd12a53 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,7 +1,10 @@ # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml +line-length = 120 target-version = "py310" +exclude = ["examples"] +[lint] select = [ "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception @@ -39,11 +42,11 @@ ignore = [ "E731", # do not assign a lambda expression, use a def ] -[flake8-pytest-style] +[lint.flake8-pytest-style] fixture-parentheses = false -[pyupgrade] +[lint.pyupgrade] keep-runtime-typing = true -[mccabe] +[lint.mccabe] max-complexity = 25 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6975684 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +ha-ecodan +========= + +A [Home Assistant](https://www.home-assistant.io/) +integration for the [Mitsubishi Ecodan](https://les.mitsubishielectric.co.uk/products/residential-heating/outdoor) +Heatpumps + + +pyecodan +-------- + +A client for interacting with the MELCloud service for controlling the heatpump. +The intention is for this to be replaced by a local controller as detailed by +@rbroker in [ecodan-ha-local](https://github.com/rbroker/ecodan-ha-local) + + +Development +----------- + +Development is based on [HACS](https://hacs.xyz/docs/categories/integrations/) + +### Testing + +A Dockerfile is provided for testing in an isolated Home Assistant instance. + +``` +docker build . -t hass +docker run --rm -it -p 8123:8123 -v ${PWD}:/hass /bin/bash + +$ source /opt/hass/core/venv/bin activate +$ ./scripts/develop +``` + +Then open a web browser at `http://localhost:8123` + + +See Also +-------- + +Home Assistant Core includes a mature integration using MELCloud with support for +heat pumps and air conditioners, however the underlying Python library (`pymelcloud`) +is no longer under active development. diff --git a/custom_components/ha_ecodan/__init__.py b/custom_components/ha_ecodan/__init__.py index a09227b..8f29a46 100644 --- a/custom_components/ha_ecodan/__init__.py +++ b/custom_components/ha_ecodan/__init__.py @@ -11,10 +11,7 @@ from .coordinator import EcodanDataUpdateCoordinator from .pyecodan import Client -PLATFORMS: list[Platform] = [ - Platform.SWITCH, - Platform.SENSOR -] +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.SENSOR, Platform.SELECT] # https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry @@ -27,10 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass), ) device = await client.get_device(entry.data[CONF_DEVICE_ID]) - hass.data[DOMAIN][entry.entry_id] = coordinator = EcodanDataUpdateCoordinator( - hass=hass, - device=device - ) + hass.data[DOMAIN][entry.entry_id] = coordinator = EcodanDataUpdateCoordinator(hass=hass, device=device) # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/ha_ecodan/config_flow.py b/custom_components/ha_ecodan/config_flow.py index 63429ff..7f51f77 100644 --- a/custom_components/ha_ecodan/config_flow.py +++ b/custom_components/ha_ecodan/config_flow.py @@ -1,4 +1,5 @@ """Adds config flow for Blueprint.""" + from __future__ import annotations import voluptuous as vol @@ -19,8 +20,8 @@ class EcodanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 async def async_step_user( - self, - user_input: dict | None = None, + self, + user_input: dict | None = None, ) -> config_entries.FlowResult: """Handle a flow initialized by the user.""" _errors = {} @@ -50,24 +51,17 @@ async def async_step_user( CONF_USERNAME, default=(user_input or {}).get(CONF_USERNAME), ): TextSelector( - TextSelectorConfig( - type=TextSelectorType.TEXT - ), + TextSelectorConfig(type=TextSelectorType.TEXT), ), vol.Required(CONF_PASSWORD): TextSelector( - TextSelectorConfig( - type=TextSelectorType.PASSWORD - ), + TextSelectorConfig(type=TextSelectorType.PASSWORD), ), } ), errors=_errors, ) - async def async_step_select_device( - self, - user_input: dict - ) -> config_entries.FlowResult: + async def async_step_select_device(self, user_input: dict) -> config_entries.FlowResult: """List the available devices to the user.""" _errors = {} @@ -77,24 +71,11 @@ async def async_step_select_device( await self.async_set_unique_id(device_id) self._abort_if_unique_id_configured() data = dict(self._account) - data.update({ - "device_id": device_id, - "device_name": device_name - }) - return self.async_create_entry( - title=data[CONF_USERNAME], data=data - ) + data.update({"device_id": device_id, "device_name": device_name}) + return self.async_create_entry(title=data[CONF_USERNAME], data=data) else: return self.async_show_form( step_id="select_device", - data_schema=vol.Schema( - { - "device": selector({ - "select": { - "options": list(self._devices.keys()) - } - }) - } - ), + data_schema=vol.Schema({"device": selector({"select": {"options": list(self._devices.keys())}})}), errors=_errors, ) diff --git a/custom_components/ha_ecodan/const.py b/custom_components/ha_ecodan/const.py index b02ab67..836fbae 100644 --- a/custom_components/ha_ecodan/const.py +++ b/custom_components/ha_ecodan/const.py @@ -1,10 +1,11 @@ """Constants for integration_blueprint.""" + from logging import Logger, getLogger LOGGER: Logger = getLogger(__package__) NAME = "Ecodan" DOMAIN = "ha_ecodan" -VERSION = "0.1.0" -ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" +VERSION = "0.1.1" +ATTRIBUTION = "https://github.com/planetmarshall/ha-ecodan.git" CONF_DEVICE_ID = "device_id" diff --git a/custom_components/ha_ecodan/entity.py b/custom_components/ha_ecodan/entity.py index bccbd42..ccdca5d 100644 --- a/custom_components/ha_ecodan/entity.py +++ b/custom_components/ha_ecodan/entity.py @@ -11,13 +11,14 @@ class EcodanEntity(CoordinatorEntity): """Base class for Ecodan entities.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__(self, coordinator: EcodanDataUpdateCoordinator) -> None: """Initialize.""" super().__init__(coordinator) self._attr_unique_id = coordinator.config_entry.entry_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, + identifiers={(DOMAIN, coordinator.device.id)}, name=NAME, model=VERSION, manufacturer=NAME, diff --git a/custom_components/ha_ecodan/manifest.json b/custom_components/ha_ecodan/manifest.json index 577cc25..2a5cb57 100644 --- a/custom_components/ha_ecodan/manifest.json +++ b/custom_components/ha_ecodan/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://github.com/planetmarshall/ha-ecodan", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/planetmarshall/ha-ecodan/issues", - "version": "0.1.0" + "version": "0.1.1" } diff --git a/custom_components/ha_ecodan/pyecodan/client.py b/custom_components/ha_ecodan/pyecodan/client.py index 961758d..ec31ef7 100644 --- a/custom_components/ha_ecodan/pyecodan/client.py +++ b/custom_components/ha_ecodan/pyecodan/client.py @@ -11,12 +11,13 @@ class Client: base_url = "https://app.melcloud.com/Mitsubishi.Wifi.Client" - def __init__(self, - username: str = os.getenv("ECODAN_USERNAME"), - password: str = os.getenv("ECODAN_PASSWORD"), - session: ClientSession = None): - """ - Create a client with the supplied credentials. + def __init__( + self, + username: str = os.getenv("ECODAN_USERNAME"), + password: str = os.getenv("ECODAN_PASSWORD"), + session: ClientSession = None, + ): + """Create a client with the supplied credentials. :param username: MELCloud username. Default is taken from the environment variable `ECODAN_USERNAME` :param password: MELCloud password. Default is taken from the environment variable `ECODAN_PASSWORD` @@ -54,7 +55,7 @@ async def login(self) -> None: "Language": 0, "AppVersion": "1.26.2.0", "Persist": True, - "CaptchaResponse": None + "CaptchaResponse": None, } async with self._session.post(login_url, json=login_data) as response: response_data = await response.json() @@ -79,14 +80,12 @@ async def list_devices(self) -> dict: structure = location["Structure"] location_name = location["Name"] for device in structure["Devices"]: - devices[device["DeviceName"]] = { - "location_name": location_name, - "id": device["DeviceID"] - } + devices[device["DeviceName"]] = {"location_name": location_name, "id": device["DeviceID"]} return devices async def __aenter__(self) -> "Client": + """Enter context manager.""" return self async def __aexit__( @@ -95,4 +94,5 @@ async def __aexit__( exc_val, exc_tb, ) -> None: + """Exit context manager.""" await self._session.__aexit__(exc_type, exc_val, exc_tb) diff --git a/custom_components/ha_ecodan/pyecodan/device.py b/custom_components/ha_ecodan/pyecodan/device.py index a16c002..16c7b81 100644 --- a/custom_components/ha_ecodan/pyecodan/device.py +++ b/custom_components/ha_ecodan/pyecodan/device.py @@ -1,31 +1,49 @@ -from enum import IntFlag -from typing import Dict +from enum import IntEnum, IntFlag from .errors import DeviceCommunicationError class EffectiveFlags(IntFlag): + """Specify the state properties to affect on a write request.""" + Update = 0x0 Power = 0x1 + OperationModeZone1 = 0x1000004000028 class DeviceStateKeys: + """Dictionary keys for the internal device state.""" + + ErrorMessage = "ErrorMessage" FlowTemperature = "FlowTemperature" OutdoorTemperature = "OutdoorTemperature" HotWaterTemperature = "TankWaterTemperature" + OperationModeZone1 = "OperationModeZone1" Power = "Power" class DevicePropertyKeys: + """Dictionary keys for device properties.""" + DeviceName = "DeviceName" DeviceID = "DeviceID" BuildingID = "BuildingID" EffectiveFlags = "EffectiveFlags" +class OperationMode(IntEnum): + """Specify the heating operation mode.""" + + Room = (0,) + Flow = (1,) + Curve = 2 + + class DeviceState: + """Representation of the device state.""" - def __init__(self, device_state: Dict): + def __init__(self, device_state: dict): + """Create a device state object from the dictionary response from MELCloud.""" self._state = {} internal_device_state = device_state["Device"] @@ -33,7 +51,8 @@ def __init__(self, device_state: Dict): DeviceStateKeys.FlowTemperature, DeviceStateKeys.Power, DeviceStateKeys.OutdoorTemperature, - DeviceStateKeys.HotWaterTemperature + DeviceStateKeys.HotWaterTemperature, + DeviceStateKeys.OperationModeZone1, ): self._state[field] = internal_device_state[field] @@ -41,63 +60,82 @@ def __init__(self, device_state: Dict): self._state[DevicePropertyKeys.DeviceName] = device_state[DevicePropertyKeys.DeviceName] self._state[DevicePropertyKeys.BuildingID] = device_state[DevicePropertyKeys.BuildingID] - def __getitem__(self, item): + """Get an item from the internal dictionary representation.""" return self._state[item] def as_dict(self): + """Return the state as a dictionary.""" return self._state + class Device: - """ - Represents an Ecodan Heat Pump device - """ - def __init__(self, client, device_state: Dict): + """Represents an Ecodan Heat Pump device.""" + + def __init__(self, client, device_state: dict): + """Construct a device from a MELCloud client and the initial device state.""" self._client = client self._state = DeviceState(device_state) @property def id(self): + """Ecodan device id.""" return self._state[DevicePropertyKeys.DeviceID] @property def name(self): + """Ecodan device name.""" return self._state[DevicePropertyKeys.DeviceName] @property def building_id(self): + """Ecodan building id.""" return self._state[DevicePropertyKeys.BuildingID] - async def _request(self, effective_flags: EffectiveFlags, **kwargs) -> Dict: + @property + def operation_mode(self) -> OperationMode: + """The heating operation mode.""" + return OperationMode(self._state[DeviceStateKeys.OperationModeZone1]) + + async def _request(self, effective_flags: EffectiveFlags, **kwargs) -> dict: state = { - DevicePropertyKeys.BuildingID: self.building_id, + # DevicePropertyKeys.BuildingID: self.building_id, DevicePropertyKeys.DeviceID: self.id, - DevicePropertyKeys.EffectiveFlags: effective_flags + DevicePropertyKeys.EffectiveFlags: effective_flags, } state.update(kwargs) return await self._client.device_request("SetAtw", state) - async def get_state(self) -> Dict: + async def get_state(self) -> dict: + """Request a state update and return as a dictionary.""" device = await self._client.get_device(self.id) self._state = device._state return self._state.as_dict() @property def data(self): + """Get the device state as a dictionary.""" return self._state.as_dict() + @staticmethod + def _check_response(response: dict): + error_message = response[DeviceStateKeys.ErrorMessage] + if error_message is not None: + raise DeviceCommunicationError(error_message) + + async def set_operation_mode(self, operation_mode: OperationMode): + """Set the operation mode to Room (auto), Flow (set flow temperature manually) or Curve.""" + response_state = await self._request(EffectiveFlags.OperationModeZone1, OperationModeZone1=operation_mode) + self._check_response(response_state) + async def power_on(self) -> None: - """ - Turn on the Heat Pump. Performs the same task as the `On` switch in the MELCloud interface - """ + """Turn on the Heat Pump. Performs the same task as the `On` switch in the MELCloud interface.""" response_state = await self._request(EffectiveFlags.Power, Power=True) if not response_state[DeviceStateKeys.Power]: raise DeviceCommunicationError("Power could not be set") async def power_off(self) -> None: - """ - Turn off the Heat Pump. Performs the same task as the `Off` switch in the MELCloud interface - """ + """Turn off the Heat Pump. Performs the same task as the `Off` switch in the MELCloud interface.""" response_state = await self._request(EffectiveFlags.Power, Power=False) if response_state[DeviceStateKeys.Power]: raise DeviceCommunicationError("Power could not be set") diff --git a/custom_components/ha_ecodan/pyecodan/errors.py b/custom_components/ha_ecodan/pyecodan/errors.py index 8f09d49..e457e2c 100644 --- a/custom_components/ha_ecodan/pyecodan/errors.py +++ b/custom_components/ha_ecodan/pyecodan/errors.py @@ -1,8 +1,14 @@ class DeviceCommunicationError(Exception): + """An error communicating with the device.""" + def __init__(self, *args: object) -> None: + """Create an error instance.""" super().__init__(*args) class DeviceAuthenticationError(Exception): + """An error authenticating with MELCloud.""" + def __init__(self, *args: object) -> None: + """Create an error instance.""" super().__init__(*args) diff --git a/custom_components/ha_ecodan/pyecodan/examples/list_devices.py b/custom_components/ha_ecodan/pyecodan/examples/list_devices.py index 518b4ad..b002060 100644 --- a/custom_components/ha_ecodan/pyecodan/examples/list_devices.py +++ b/custom_components/ha_ecodan/pyecodan/examples/list_devices.py @@ -1,6 +1,7 @@ import asyncio import pyecodan +from pyecodan.device import OperationMode async def main(): @@ -8,7 +9,11 @@ async def main(): devices = await client.list_devices() device = devices["Naze View"] device = await client.get_device(device["id"]) + + await device.set_operation_mode(OperationMode.Room) print(device.name) + print(device.id) + print(device.operation_mode) if __name__ == "__main__": diff --git a/custom_components/ha_ecodan/select.py b/custom_components/ha_ecodan/select.py new file mode 100644 index 0000000..c11e76e --- /dev/null +++ b/custom_components/ha_ecodan/select.py @@ -0,0 +1,53 @@ +from homeassistant.components.select import SelectEntityDescription, SelectEntity + +from custom_components.ha_ecodan import EcodanDataUpdateCoordinator, DOMAIN +from .pyecodan.device import DeviceStateKeys, OperationMode +from .entity import EcodanEntity + + +ENTITY_DESCRIPTIONS = (SelectEntityDescription(key="ha_ecodan", name="Operation Mode", icon="mdi:cog"),) + + +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( + EcodanSelect( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in ENTITY_DESCRIPTIONS + ) + + +class EcodanSelect(EcodanEntity, SelectEntity): + """A Select Entity for the Ecodan platform.""" + + _options = { + "Room Thermostat": OperationMode.Room, + "Flow Temperature": OperationMode.Flow, + "Weather Compensation": OperationMode.Curve, + } + + def __init__(self, coordinator: EcodanDataUpdateCoordinator, entity_description: SelectEntityDescription): + """Create an Selection Entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + + @property + def options(self) -> list[str]: + """Get the available operation mode options.""" + return list(self._options.keys()) + + @property + def current_option(self) -> str | None: + """Get the currently selected operation mode.""" + operation_mode = self.coordinator.data.get(DeviceStateKeys.OperationModeZone1) + selected_option = [key for key, value in self._options.items() if value == operation_mode] + return selected_option[0] if len(selected_option) > 0 else None + + async def async_select_option(self, option: str) -> None: + """Set the selected option.""" + option_to_select = self._options.get(option) + if option_to_select is not None: + await self.coordinator.device.set_operation_mode(option_to_select) diff --git a/custom_components/ha_ecodan/sensor.py b/custom_components/ha_ecodan/sensor.py index f588e9a..af17e5d 100644 --- a/custom_components/ha_ecodan/sensor.py +++ b/custom_components/ha_ecodan/sensor.py @@ -20,31 +20,32 @@ class EcodanSensorEntityDescription(SensorEntityDescription): key="ha_ecodan", name="Flow Temperature", icon="mdi:thermometer", - native_unit_of_measurement = UnitOfTemperature.CELSIUS, - device_class = SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - state_key = DeviceStateKeys.FlowTemperature + state_key=DeviceStateKeys.FlowTemperature, ), EcodanSensorEntityDescription( key="ha_ecodan", name="Outdoor Temperature", icon="mdi:thermometer", - native_unit_of_measurement = UnitOfTemperature.CELSIUS, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - state_key = DeviceStateKeys.OutdoorTemperature + state_key=DeviceStateKeys.OutdoorTemperature, ), EcodanSensorEntityDescription( key="ha_ecodan", name="Hot Water Temperature", icon="mdi:thermometer", - native_unit_of_measurement = UnitOfTemperature.CELSIUS, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - state_key=DeviceStateKeys.HotWaterTemperature + state_key=DeviceStateKeys.HotWaterTemperature, ), ) + async def async_setup_entry(hass, entry, async_add_devices): """Set up the sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] @@ -56,6 +57,7 @@ async def async_setup_entry(hass, entry, async_add_devices): for entity_description in ENTITY_DESCRIPTIONS ) + class EcodanSensor(EcodanEntity, SensorEntity): """A Sensor Entity for the Ecodan platform.""" diff --git a/custom_components/ha_ecodan/switch.py b/custom_components/ha_ecodan/switch.py index b36d2d1..cd214ff 100644 --- a/custom_components/ha_ecodan/switch.py +++ b/custom_components/ha_ecodan/switch.py @@ -1,4 +1,5 @@ """Switch platform for integration_blueprint.""" + from __future__ import annotations from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -8,13 +9,7 @@ from .entity import EcodanEntity from .pyecodan.device import DeviceStateKeys -ENTITY_DESCRIPTIONS = ( - SwitchEntityDescription( - key="ha_ecodan", - name="Power Switch", - icon="mdi:power" - ), -) +ENTITY_DESCRIPTIONS = (SwitchEntityDescription(key="ha_ecodan", name="Power Switch", icon="mdi:power"),) async def async_setup_entry(hass, entry, async_add_devices): diff --git a/test/conftest.py b/test/conftest.py index 9198ffd..ca8fc00 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -2,6 +2,8 @@ from unittest.mock import Mock +from data.melcloud import MelCloudData + @pytest.fixture def coordinator(): @@ -14,3 +16,8 @@ def _coordinator(_data: dict=None): return mock return _coordinator + + +@pytest.fixture +def melcloud(): + return MelCloudData() diff --git a/test/data/__init__.py b/test/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/data/melcloud.py b/test/data/melcloud.py new file mode 100644 index 0000000..ed6e492 --- /dev/null +++ b/test/data/melcloud.py @@ -0,0 +1,466 @@ +_device_list = { + "ID": 548104, + "Name": "Naze View", + "AddressLine1": "Naze View, Bankhall", + "AddressLine2": "Chapel en le Frith", + "City": "High Peak", + "Postcode": "SK23 9UB", + "Latitude": 53.305701458758442, + "Longitude": -1.9248211304223717, + "District": 1029, + "FPDefined": False, + "FPEnabled": False, + "FPMinTemperature": 14, + "FPMaxTemperature": 16, + "HMDefined": True, + "HMEnabled": False, + "HMStartDate": None, + "HMEndDate": None, + "BuildingType": 2, + "PropertyType": 1, + "DateBuilt": "1850-03-01T00:00:00", + "HasGasSupply": False, + "LocationLookupDate": "2023-03-07T10:56:00.76", + "Country": 237, + "TimeZoneContinent": 3, + "TimeZoneCity": 43, + "TimeZone": 118, + "Location": 94732, + "CoolingDisabled": False, + "LinkToMELCloudHome": False, + "Expanded": True, + "Structure": { + "Floors": [], + "Areas": [], + "Devices": [ + { + "DeviceID": 67204455, + "DeviceName": "Naze View", + "BuildingID": 548104, + "BuildingName": None, + "FloorID": None, + "FloorName": None, + "AreaID": None, + "AreaName": None, + "ImageID": -9, + "InstallationDate": "2023-03-01T00:00:00", + "LastServiceDate": "2023-03-01T00:00:00", + "Presets": [], + "OwnerID": 706869, + "OwnerName": None, + "OwnerEmail": None, + "AccessLevel": 4, + "DirectAccess": False, + "EndDate": "2500-01-01T00:00:00", + "Zone1Name": None, + "Zone2Name": None, + "MinTemperature": 0, + "MaxTemperature": 40, + "HideVaneControls": False, + "HideDryModeControl": False, + "HideRoomTemperature": False, + "HideSupplyTemperature": False, + "HideOutdoorTemperature": False, + "EstimateAtaEnergyProductionOptIn": False, + "EstimateAtaEnergyProduction": False, + "BuildingCountry": None, + "OwnerCountry": None, + "AdaptorType": 3, + "LinkedDevice": None, + "Type": 1, + "MacAddress": "e8:c7:cf:0e:a4:1a", + "SerialNumber": "2239911012", + "Device": { + "PCycleActual": 0, + "ErrorMessages": "", + "DeviceType": 1, + "FTCVersion": 1801, + "FTCRevision": "r0", + "LastFTCVersion": 0, + "LastFTCRevision": None, + "FTCModel": 3, + "RefridgerentAddress": 0, + "DipSwitch1": 78, + "DipSwitch2": 129, + "DipSwitch3": 16, + "DipSwitch4": 0, + "DipSwitch5": 2, + "DipSwitch6": 0, + "HasThermostatZone1": True, + "HasThermostatZone2": True, + "TemperatureIncrement": 0.5, + "DefrostMode": 0, + "HeatPumpFrequency": 0, + "MaxSetTemperature": 50.0, + "MinSetTemperature": 30.0, + "RoomTemperatureZone1": 15.5, + "RoomTemperatureZone2": -39.0, + "OutdoorTemperature": 11.0, + "FlowTemperature": 19.0, + "FlowTemperatureZone1": 25.0, + "FlowTemperatureZone2": 25.0, + "FlowTemperatureBoiler": 25.0, + "ReturnTemperature": 19.0, + "ReturnTemperatureZone1": 25.0, + "ReturnTemperatureZone2": 25.0, + "ReturnTemperatureBoiler": 25.0, + "BoilerStatus": False, + "BoosterHeater1Status": False, + "BoosterHeater2Status": False, + "BoosterHeater2PlusStatus": False, + "ImmersionHeaterStatus": False, + "WaterPump1Status": False, + "WaterPump2Status": False, + "WaterPump3Status": False, + "ValveStatus3Way": False, + "ValveStatus2Way": False, + "WaterPump4Status": False, + "ValveStatus2Way2a": False, + "ValveStatus2Way2b": False, + "TankWaterTemperature": 32.5, + "UnitStatus": 0, + "HeatingFunctionEnabled": True, + "ServerTimerEnabled": False, + "ThermostatStatusZone1": False, + "ThermostatStatusZone2": False, + "ThermostatTypeZone1": 0, + "ThermostatTypeZone2": 2, + "MixingTankWaterTemperature": 25.0, + "CondensingTemperature": 40.57, + "DemandPercentage": 100, + "ConfiguredDemandPercentage": None, + "HasDemandSideControl": True, + "DailyHeatingEnergyConsumed": 3.4, + "DailyCoolingEnergyConsumed": 0.0, + "DailyHotWaterEnergyConsumed": 2.19, + "DailyHeatingEnergyProduced": 14.59, + "DailyCoolingEnergyProduced": 0.0, + "DailyHotWaterEnergyProduced": 6.77, + "DailyLegionellaActivationCounter": 1, + "LastLegionellaActivationTime": "2024-04-30T04:04:00Z", + "EffectiveFlags": 0, + "LastEffectiveFlags": 0, + "Power": True, + "EcoHotWater": False, + "OperationMode": 0, + "OperationModeZone1": 0, + "OperationModeZone2": 2, + "SetTemperatureZone1": 10.0, + "SetTemperatureZone2": 20.0, + "SetTankWaterTemperature": 52.0, + "TargetHCTemperatureZone1": 10.0, + "TargetHCTemperatureZone2": 35.0, + "ForcedHotWaterMode": False, + "HolidayMode": False, + "ProhibitHotWater": True, + "ProhibitHeatingZone1": False, + "ProhibitHeatingZone2": False, + "ProhibitCoolingZone1": False, + "ProhibitCoolingZone2": False, + "ServerTimerDesired": False, + "SecondaryZoneHeatCurve": False, + "SetHeatFlowTemperatureZone1": 27.0, + "SetHeatFlowTemperatureZone2": 20.0, + "SetCoolFlowTemperatureZone1": 20.0, + "SetCoolFlowTemperatureZone2": 20.0, + "DECCReport": False, + "CSVReport1min": False, + "Zone2Master": False, + "DailyEnergyConsumedDate": "2024-05-04T00:00:00", + "DailyEnergyProducedDate": "2024-05-04T00:00:00", + "CurrentEnergyConsumed": 0, + "CurrentEnergyProduced": 0, + "CurrentEnergyMode": None, + "HeatingEnergyConsumedRate1": 0, + "HeatingEnergyConsumedRate2": 0, + "CoolingEnergyConsumedRate1": 0, + "CoolingEnergyConsumedRate2": 0, + "HotWaterEnergyConsumedRate1": 0, + "HotWaterEnergyConsumedRate2": 0, + "HeatingEnergyProducedRate1": 0, + "HeatingEnergyProducedRate2": 0, + "CoolingEnergyProducedRate1": 0, + "CoolingEnergyProducedRate2": 0, + "HotWaterEnergyProducedRate1": 0, + "HotWaterEnergyProducedRate2": 0, + "ErrorCode2Digit": 0, + "SpSubDivisionsToWrite": 0, + "SpSubDivisionsToRead": 0, + "SpState": 0, + "SpSubDivisionsWriteInProgress": 0, + "SpSubDivisionsReadInProgress": 0, + "InitialSettingsData": None, + "InitialSettingsTimestamp": None, + "SupportsHourlyEnergyReport": False, + "HasZone2": False, + "HasSimplifiedZone2": False, + "CanHeat": True, + "CanCool": False, + "HasHotWaterTank": True, + "CanSetTankTemperature": True, + "CanSetEcoHotWater": True, + "HasEnergyConsumedMeter": True, + "HasEnergyProducedMeter": True, + "CanMeasureEnergyProduced": False, + "CanMeasureEnergyConsumed": False, + "Zone1InRoomMode": True, + "Zone2InRoomMode": False, + "Zone1InHeatMode": True, + "Zone2InHeatMode": True, + "Zone1InCoolMode": False, + "Zone2InCoolMode": False, + "AllowDualRoomTemperature": False, + "IsGeodan": False, + "HasEcoCuteSettings": False, + "HasFTC45Settings": True, + "HasFTC6Settings": True, + "CanEstimateEnergyUsage": True, + "CanUseRoomTemperatureCooling": False, + "IsFtcModelSupported": True, + "MaxTankTemperature": 60.0, + "IdleZone1": True, + "IdleZone2": True, + "MinPcycle": 1, + "MaxPcycle": 1, + "MaxOutdoorUnits": 255, + "MaxIndoorUnits": 255, + "MaxTemperatureControlUnits": 0, + "ModelCode": "027a", + "DeviceID": 67204455, + "MacAddress": "e8:c7:cf:0e:a4:1a", + "SerialNumber": "2239911012", + "TimeZoneID": 118, + "DiagnosticMode": 0, + "DiagnosticEndDate": None, + "ExpectedCommand": 1, + "Owner": 706869, + "DetectedCountry": None, + "AdaptorType": 3, + "FirmwareDeployment": None, + "FirmwareUpdateAborted": False, + "LinkedDevice": None, + "WifiSignalStrength": -40, + "WifiAdapterStatus": "NORMAL", + "Position": "Unknown", + "PCycle": 2, + "PCycleConfigured": None, + "RecordNumMax": 1, + "LastTimeStamp": "2024-05-05T08:52:00", + "ErrorCode": 8000, + "HasError": False, + "LastReset": "2023-03-07T11:15:27.407", + "FlashWrites": 6, + "Scene": None, + "TemperatureIncrementOverride": 0, + "SSLExpirationDate": "2037-12-31T00:00:00", + "SPTimeout": 1, + "Passcode": None, + "ServerCommunicationDisabled": False, + "ConsecutiveUploadErrors": 0, + "DoNotRespondAfter": None, + "OwnerRoleAccessLevel": 1, + "OwnerCountry": 237, + "HideEnergyReport": False, + "ExceptionHash": None, + "ExceptionDate": None, + "ExceptionCount": None, + "Rate1StartTime": None, + "Rate2StartTime": None, + "ProtocolVersion": 0, + "UnitVersion": 0, + "FirmwareAppVersion": 37000, + "FirmwareWebVersion": 0, + "FirmwareWlanVersion": 0, + "LinkToMELCloudHome": False, + "LinkedByUserFromMELCloudHome": "00000000-0000-0000-0000-000000000000", + "EffectivePCycle": 1, + "MqttFlags": 9, + "HasErrorMessages": False, + "Offline": False, + "Units": [ + { + "ID": 1, + "Device": 0, + "SerialNumber": "2g02708", + "ModelNumber": 883, + "Model": "PUZ-WM85VAA", + "UnitType": 0, + "IsIndoor": False, + }, + { + "ID": 2, + "Device": 0, + "SerialNumber": "3b01890", + "ModelNumber": 940, + "Model": "PAC-IF072B-E", + "UnitType": 1, + "IsIndoor": True, + }, + ], + }, + "DiagnosticMode": 0, + "DiagnosticEndDate": None, + "Location": 94732, + "DetectedCountry": None, + "Registrations": 51, + "LocalIPAddress": None, + "TimeZone": 118, + "RegistReason": "CONFIG", + "ExpectedCommand": 1, + "RegistRetry": 0, + "DateCreated": "2023-03-07T09:53:55.213Z", + "FirmwareDeployment": None, + "FirmwareUpdateAborted": False, + "Permissions": { + "CanSetForcedHotWater": True, + "CanSetOperationMode": True, + "CanSetPower": True, + "CanSetTankWaterTemperature": True, + "CanSetEcoHotWater": False, + "CanSetFlowTemperature": True, + "CanSetTemperatureIncrementOverride": True, + }, + } + ], + "Clients": [], + }, + "AccessLevel": 4, + "DirectAccess": True, + "MinTemperature": 0, + "MaxTemperature": 40, + "Owner": None, + "EndDate": "2500-01-01T00:00:00", + "iDateBuilt": None, + "QuantizedCoordinates": {"Latitude": 53.3, "Longitude": -1.925}, +} + +_example_request = { + "EffectiveFlags": 281475043819552, + "OperationModeZone1": 1, + "DeviceID": 67204455, +} + +_example_response = { + "EffectiveFlags": 281475043819560, + "LocalIPAddress": None, + "SetTemperatureZone1": 10.0, + "SetTemperatureZone2": 20.0, + "RoomTemperatureZone1": 15.5, + "RoomTemperatureZone2": -39.0, + "OperationMode": 0, + "OperationModeZone1": 1, + "OperationModeZone2": 2, + "WeatherObservations": [ + { + "Date": "2024-05-05T09:00:00", + "Sunrise": "2024-05-05T05:25:00", + "Sunset": "2024-05-05T20:44:00", + "Condition": 116, + "ID": 1593090925, + "Humidity": 72, + "Temperature": 12, + "Icon": "wsymbol_0002_sunny_intervals", + "ConditionName": "Partly Cloudy", + "Day": 0, + "WeatherType": 0, + }, + { + "Date": "2024-05-05T15:00:00", + "Sunrise": "2024-05-05T05:25:00", + "Sunset": "2024-05-05T20:44:00", + "Condition": 353, + "ID": 1593090927, + "Humidity": 70, + "Temperature": 15, + "Icon": "wsymbol_0009_light_rain_showers", + "ConditionName": "Light rain shower", + "Day": 0, + "WeatherType": 1, + }, + { + "Date": "2024-05-06T03:00:00", + "Sunrise": "2024-05-06T05:23:00", + "Sunset": "2024-05-06T20:46:00", + "Condition": 176, + "ID": 1594613571, + "Humidity": 89, + "Temperature": 9, + "Icon": "wsymbol_0025_light_rain_showers_night", + "ConditionName": "Patchy rain nearby", + "Day": 0, + "WeatherType": 2, + }, + { + "Date": "2024-05-06T15:00:00", + "Sunrise": "2024-05-06T05:23:00", + "Sunset": "2024-05-06T20:46:00", + "Condition": 353, + "ID": 1594613575, + "Humidity": 72, + "Temperature": 15, + "Icon": "wsymbol_0009_light_rain_showers", + "ConditionName": "Light rain shower", + "Day": 1, + "WeatherType": 1, + }, + ], + "ErrorMessage": None, + "ErrorCode": 8000, + "SetHeatFlowTemperatureZone1": 25.0, + "SetHeatFlowTemperatureZone2": 20.0, + "SetCoolFlowTemperatureZone1": 20.0, + "SetCoolFlowTemperatureZone2": 20.0, + "HCControlType": 1, + "TankWaterTemperature": 32.0, + "SetTankWaterTemperature": 52.0, + "ForcedHotWaterMode": False, + "UnitStatus": 0, + "OutdoorTemperature": 12.0, + "EcoHotWater": False, + "Zone1Name": None, + "Zone2Name": None, + "HolidayMode": False, + "ProhibitZone1": False, + "ProhibitZone2": False, + "ProhibitHotWater": True, + "TemperatureIncrementOverride": 0, + "IdleZone1": True, + "IdleZone2": True, + "DemandPercentage": 100, + "DeviceID": 67204455, + "DeviceType": 1, + "LastCommunication": "2024-05-05T08:05:37.221", + "NextCommunication": "2024-05-05T08:06:37.221", + "Power": True, + "HasPendingCommand": False, + "Offline": False, + "Scene": None, + "SceneOwner": None, +} + +class MelCloudData: + @property + def device_list(self): + return _device_list.copy() + + @property + def device_state(self) -> dict: + return _device_list["Structure"]["Devices"][0].copy() + + def device_state_with(self, **kwargs) -> dict: + device_state = _device_list["Structure"]["Devices"][0].copy() + device_state["Device"].update(kwargs) + return device_state + + @property + def response(self) -> dict: + return _example_response.copy() + + @property + def request(self) -> dict: + return _example_request.copy() + + def request_with(self, **kwargs) -> dict: + request = _example_request.copy() + request.update(kwargs) + return request diff --git a/test/pyecodan/test_operation_modes.py b/test/pyecodan/test_operation_modes.py new file mode 100644 index 0000000..0e8cb99 --- /dev/null +++ b/test/pyecodan/test_operation_modes.py @@ -0,0 +1,36 @@ +import pytest + +from custom_components.ha_ecodan.pyecodan import Device +from custom_components.ha_ecodan.pyecodan.device import OperationMode + +from unittest.mock import AsyncMock, Mock + + +@pytest.mark.parametrize("operation_mode_index, operation_mode", [ + (0, OperationMode.Room), + (1, OperationMode.Flow), + (2, OperationMode.Curve), +]) +def test_operation_mode_from_device_state(melcloud, operation_mode_index, operation_mode): + client = Mock() + device = Device(client, melcloud.device_state_with(OperationModeZone1=operation_mode_index)) + + assert device.operation_mode == operation_mode + + +@pytest.mark.parametrize("operation_mode_index, operation_mode", [ + (0, OperationMode.Room), + (1, OperationMode.Flow), + (2, OperationMode.Curve), +]) +@pytest.mark.asyncio +async def test_set_operation_mode(melcloud, operation_mode_index, operation_mode): + client = Mock() + client.device_request = AsyncMock(return_value=melcloud.response) + device = Device(client, melcloud.device_state) + + await device.set_operation_mode(operation_mode) + + client.device_request.assert_called_with( + "SetAtw", + melcloud.request_with(EffectiveFlags=281475043819560, OperationModeZone1=operation_mode_index)) diff --git a/test/test_select_operation_mode.py b/test/test_select_operation_mode.py new file mode 100644 index 0000000..5c0e66b --- /dev/null +++ b/test/test_select_operation_mode.py @@ -0,0 +1,42 @@ +from unittest.mock import AsyncMock + +import pytest + +from custom_components.ha_ecodan.select import EcodanSelect, ENTITY_DESCRIPTIONS +from custom_components.ha_ecodan.pyecodan.device import DeviceStateKeys, OperationMode + + +def test_select_modes(coordinator): + sensor = EcodanSelect(coordinator(), ENTITY_DESCRIPTIONS[0]) + assert sensor.options == [ + "Room Thermostat", + "Flow Temperature", + "Weather Compensation" + ] + + +@pytest.mark.parametrize("operation_mode, option", [ + (OperationMode.Room, "Room Thermostat"), + (OperationMode.Flow, "Flow Temperature"), + (OperationMode.Curve, "Weather Compensation") +]) +def test_current_selection(coordinator, operation_mode, option): + data = { DeviceStateKeys.OperationModeZone1: operation_mode} + sensor = EcodanSelect(coordinator(data), ENTITY_DESCRIPTIONS[0]) + assert sensor.current_option == option + + +@pytest.mark.parametrize("operation_mode, option", [ + (OperationMode.Room, "Room Thermostat"), + (OperationMode.Flow, "Flow Temperature"), + (OperationMode.Curve, "Weather Compensation") +]) +@pytest.mark.asyncio +async def test_select_option(coordinator, operation_mode, option): + data = { DeviceStateKeys.OperationModeZone1: operation_mode} + obj = coordinator(data) + obj.device.set_operation_mode = AsyncMock() + sensor = EcodanSelect(obj, ENTITY_DESCRIPTIONS[0]) + await sensor.async_select_option(option) + + obj.device.set_operation_mode.assert_awaited_with(operation_mode)