diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 9aea4badcc7ac2..b1ffdfa4376f03 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 with: fetch-depth: 0 @@ -67,7 +67,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.4.0 @@ -100,7 +100,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -198,7 +198,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set build additional args run: | @@ -241,7 +241,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -280,7 +280,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 30891c391de9fc..b251b3d522fdc0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -167,7 +167,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.4.0 @@ -211,7 +211,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.4.0 id: python @@ -265,7 +265,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.4.0 id: python @@ -322,7 +322,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.4.0 id: python @@ -368,7 +368,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.4.0 id: python @@ -495,7 +495,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.4.0 @@ -559,7 +559,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.4.0 @@ -592,7 +592,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.4.0 @@ -626,7 +626,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.4.0 @@ -671,7 +671,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.4.0 @@ -720,7 +720,7 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.4.0 @@ -775,7 +775,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.4.0 @@ -898,7 +898,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.4.0 @@ -970,7 +970,7 @@ jobs: - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index 1dcbcfabd3a056..f38c30053fd132 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.4.0 @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.4.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 20b758d032f9d6..67376235ccbb4e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -22,7 +22,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Get information id: info @@ -79,7 +79,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -116,7 +116,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.2.0 + uses: actions/checkout@v3.3.0 - name: Download env_file uses: actions/download-artifact@v3 diff --git a/CODEOWNERS b/CODEOWNERS index f2b941bd2c1906..b5b7ff37cda6ae 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -733,6 +733,8 @@ build.json @home-assistant/supervisor /homeassistant/components/msteams/ @peroyvind /homeassistant/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys +/homeassistant/components/multimatic/ @thomasgermain +/tests/components/multimatic/ @thomasgermain /homeassistant/components/mutesync/ @currentoor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core diff --git a/homeassistant/components/multimatic/__init__.py b/homeassistant/components/multimatic/__init__.py new file mode 100644 index 00000000000000..83b7de7465c864 --- /dev/null +++ b/homeassistant/components/multimatic/__init__.py @@ -0,0 +1,116 @@ +"""The multimatic integration.""" +import asyncio +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import ( + COORDINATOR_LIST, + COORDINATORS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + PLATFORMS, + SERVICES_HANDLER, +) +from .coordinator import MultimaticApi, MultimaticCoordinator +from .service import SERVICES, MultimaticServiceHandler + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the multimatic integration.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up multimatic from a config entry.""" + + api: MultimaticApi = MultimaticApi(hass, entry) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(entry.unique_id, {}) + hass.data[DOMAIN][entry.unique_id].setdefault(COORDINATORS, {}) + + for coord in COORDINATOR_LIST.items(): + update_interval = ( + coord[1] + if coord[1] + else timedelta( + minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + ) + m_coord = MultimaticCoordinator( + hass, + name=f"{DOMAIN}_{coord[0]}", + api=api, + method="get_" + coord[0], + update_interval=update_interval, + ) + hass.data[DOMAIN][entry.unique_id][COORDINATORS][coord[0]] = m_coord + _LOGGER.debug("Adding %s coordinator", m_coord.name) + await m_coord.async_refresh() + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + async def logout(event): + await api.logout() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, logout) + + await async_setup_service(api, hass) + + return True + + +async def async_setup_service(api: MultimaticApi, hass): + """Set up services.""" + if not hass.data.get(SERVICES_HANDLER): + service_handler = MultimaticServiceHandler(api, hass) + for service_key in SERVICES: + schema = SERVICES[service_key]["schema"] + if not SERVICES[service_key].get("entity", False): + hass.services.async_register( + DOMAIN, service_key, service_handler.service_call, schema=schema + ) + hass.data[DOMAIN][SERVICES_HANDLER] = service_handler + + +async def async_unload_services(hass): + """Remove service when integration is removed.""" + service_handler = hass.data[DOMAIN].get(SERVICES_HANDLER, None) + if service_handler: + for service_name in SERVICES: + hass.services.async_remove(DOMAIN, service_name) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *( + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ) + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.unique_id) + + _LOGGER.debug("Remaining data for multimatic %s", hass.data[DOMAIN]) + + if ( + len(hass.data[DOMAIN]) == 1 + and hass.data[DOMAIN].get(SERVICES_HANDLER, None) is not None + ): + await async_unload_services(hass) + hass.data[DOMAIN].pop(SERVICES_HANDLER) + + return unload_ok diff --git a/homeassistant/components/multimatic/binary_sensor.py b/homeassistant/components/multimatic/binary_sensor.py new file mode 100644 index 00000000000000..3e5ff0dee44433 --- /dev/null +++ b/homeassistant/components/multimatic/binary_sensor.py @@ -0,0 +1,612 @@ +"""Interfaces with Multimatic binary sensors.""" +from __future__ import annotations + +import logging + +from pymultimatic.model import Device, OperatingModes, QuickModes, Room, SettingModes + +from homeassistant.components.binary_sensor import ( + DOMAIN, + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import EntityCategory +from homeassistant.util import slugify + +from .const import ( + ATTR_DURATION, + DHW, + DOMAIN as MULTIMATIC, + FACILITY_DETAIL, + GATEWAY, + HOLIDAY_MODE, + HVAC_STATUS, + QUICK_MODE, + ROOMS, +) +from .coordinator import MultimaticCoordinator +from .entities import MultimaticEntity +from .utils import get_coordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the multimatic binary sensor platform.""" + sensors = [] + + dhw_coo = get_coordinator(hass, DHW, entry.unique_id) + if dhw_coo.data and dhw_coo.data.circulation: + sensors.append(CirculationSensor(dhw_coo)) + + hvac_coo = get_coordinator(hass, HVAC_STATUS, entry.unique_id) + detail_coo = get_coordinator(hass, FACILITY_DETAIL, entry.unique_id) + gw_coo = get_coordinator(hass, GATEWAY, entry.unique_id) + if hvac_coo.data: + sensors.append(BoxOnline(hvac_coo, detail_coo, gw_coo)) + sensors.append(BoxUpdate(hvac_coo, detail_coo, gw_coo)) + sensors.append(MultimaticErrors(hvac_coo)) + + if hvac_coo.data.boiler_status: + sensors.append(BoilerStatus(hvac_coo)) + + rooms_coo = get_coordinator(hass, ROOMS, entry.unique_id) + if rooms_coo.data: + for room in rooms_coo.data: + sensors.append(RoomWindow(rooms_coo, room)) + for device in room.devices: + if device.device_type in ("VALVE", "THERMOSTAT"): + sensors.append(RoomDeviceChildLock(rooms_coo, device, room)) + sensors.append(RoomDeviceBattery(rooms_coo, device)) + sensors.append(RoomDeviceConnectivity(rooms_coo, device)) + + sensors.extend( + [ + HolidayModeSensor(get_coordinator(hass, HOLIDAY_MODE, entry.unique_id)), + QuickModeSensor(get_coordinator(hass, QUICK_MODE, entry.unique_id)), + ] + ) + + _LOGGER.info("Adding %s binary sensor entities", len(sensors)) + + async_add_entities(sensors) + return True + + +class CirculationSensor(MultimaticEntity, BinarySensorEntity): + """Binary sensor for circulation running on or not.""" + + def __init__(self, coordinator: MultimaticCoordinator) -> None: + """Initialize entity.""" + super().__init__(coordinator, DOMAIN, "dhw_circulation") + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + a_mode = self.active_mode + return ( + a_mode.current in (OperatingModes.ON, QuickModes.HOTWATER_BOOST) + or a_mode.sub == SettingModes.ON + ) + + @property + def available(self): + """Return True if entity is available.""" + return ( + super().available + and self.coordinator.data + and self.coordinator.data.circulation + ) + + @property + def active_mode(self): + """Return the active mode of the circulation.""" + return self.coordinator.api.get_active_mode(self.coordinator.data.circulation) + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self.coordinator.data.circulation.name + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.RUNNING + + +class RoomWindow(MultimaticEntity, BinarySensorEntity): + """multimatic window binary sensor.""" + + def __init__(self, coordinator: MultimaticCoordinator, room: Room) -> None: + """Initialize entity.""" + super().__init__( + coordinator, DOMAIN, f"{room.name}_{BinarySensorDeviceClass.WINDOW}" + ) + self._room_id = room.id + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.room.window_open + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.room + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.WINDOW + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self.room.name if self.room else None + + @property + def room(self) -> Room: + """Return the room.""" + return self.coordinator.find_component(self._room_id) + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class RoomDeviceEntity(MultimaticEntity, BinarySensorEntity): + """Base class for ambisense device.""" + + def __init__( + self, coordinator: MultimaticCoordinator, device: Device, extra_id + ) -> None: + """Initialize device.""" + MultimaticEntity.__init__( + self, coordinator, DOMAIN, f"{device.sgtin}_{extra_id}" + ) + self._sgtin = device.sgtin + + @property + def device_info(self): + """Return device specific attributes.""" + device = self.device + return { + "identifiers": {(MULTIMATIC, device.sgtin)}, + "name": device.name, + "manufacturer": "Vaillant", + "model": device.device_type, + } + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + device = self.device + return { + "device_id": device.sgtin, + "battery_low": device.battery_low, + "connected": not device.radio_out_of_reach, + } + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.device + + @property + def device(self): + """Return the device.""" + for room in self.coordinator.data: + for device in room.devices: + if device.sgtin == self._sgtin: + return device + return None + + @property + def name(self) -> str | None: + """Return the name of the entity.""" + return f"{self.device.name} {self.device_class}" + + +class RoomDeviceChildLock(RoomDeviceEntity): + """Binary sensor for valve child lock. + + At multimatic API, the lock is set at a room level, but it applies to all + devices inside a room. + """ + + def __init__( + self, coordinator: MultimaticCoordinator, device: Device, room: Room + ) -> None: + """Initialize entity.""" + super().__init__(coordinator, device, BinarySensorDeviceClass.LOCK) + self._room_id = room.id + + @property + def is_on(self): + """According to the doc, true means unlock, false lock.""" + return not self.room.child_lock + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.room + + @property + def room(self) -> Room: + """Return the room.""" + return self.coordinator.find_component(self._room_id) + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.LOCK + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class RoomDeviceBattery(RoomDeviceEntity): + """Represent a device battery.""" + + def __init__(self, coordinator: MultimaticCoordinator, device: Device) -> None: + """Initialize entity.""" + super().__init__(coordinator, device, BinarySensorDeviceClass.BATTERY) + + @property + def is_on(self): + """According to the doc, true means normal, false low.""" + return self.device.battery_low + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.BATTERY + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class RoomDeviceConnectivity(RoomDeviceEntity): + """Device in room is out of reach or not.""" + + def __init__(self, coordinator: MultimaticCoordinator, device: Device) -> None: + """Initialize entity.""" + super().__init__(coordinator, device, BinarySensorDeviceClass.CONNECTIVITY) + + @property + def is_on(self): + """According to the doc, true means connected, false disconnected.""" + return not self.device.radio_out_of_reach + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.CONNECTIVITY + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class VRBoxEntity(MultimaticEntity, BinarySensorEntity): + """multimatic gateway device (ex: VR920).""" + + def __init__( + self, + coord: MultimaticCoordinator, + detail_coo: MultimaticCoordinator, + gw_coo: MultimaticCoordinator, + comp_id, + ): + """Initialize entity.""" + MultimaticEntity.__init__(self, coord, DOMAIN, comp_id) + self._detail_coo = detail_coo + self._gw_coo = gw_coo + + @property + def device_info(self): + """Return device specific attributes.""" + if self._detail_coo.data: + detail = self._detail_coo.data + return { + "identifiers": {(MULTIMATIC, detail.serial_number)}, + "connections": {(CONNECTION_NETWORK_MAC, detail.ethernet_mac)}, + "name": self._gw_coo.data, + "manufacturer": "Vaillant", + "model": self._gw_coo.data, + "sw_version": detail.firmware_version, + } + + +class BoxUpdate(VRBoxEntity): + """Update binary sensor.""" + + def __init__( + self, + coord: MultimaticCoordinator, + detail_coo: MultimaticCoordinator, + gw_coo: MultimaticCoordinator, + ) -> None: + """Init.""" + super().__init__( + coord, + detail_coo, + gw_coo, + "Multimatic_system_update", + ) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return not self.coordinator.data.is_up_to_date + + @property + def name(self) -> str | None: + """Return the name of the entity.""" + return "Multimatic system update" + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.UPDATE + + +class BoxOnline(VRBoxEntity): + """Check if box is online.""" + + def __init__( + self, + coord: MultimaticCoordinator, + detail_coo: MultimaticCoordinator, + gw_coo: MultimaticCoordinator, + ) -> None: + """Init.""" + super().__init__(coord, detail_coo, gw_coo, "multimatic_system_online") + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.coordinator.data.is_online + + @property + def name(self): + """Return the name of the entity.""" + return "Multimatic system Online" + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.CONNECTIVITY + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class BoilerStatus(MultimaticEntity, BinarySensorEntity): + """Check if there is some error.""" + + def __init__(self, coordinator: MultimaticCoordinator) -> None: + """Initialize entity.""" + MultimaticEntity.__init__( + self, + coordinator, + DOMAIN, + coordinator.data.boiler_status.device_name, + ) + self._name = coordinator.data.boiler_status.device_name + self._boiler_id = slugify(self._name) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.boiler_status and self.boiler_status.is_error + + @property + def state_attributes(self): + """Return the state attributes.""" + if self.boiler_status: + return { + "status_code": self.boiler_status.status_code, + "title": self.boiler_status.title, + "timestamp": self.boiler_status.timestamp, + } + + @property + def device_info(self): + """Return device specific attributes.""" + return { + "identifiers": {(MULTIMATIC, self._boiler_id)}, + "name": self._name, + "manufacturer": "Vaillant", + "model": self._name, + } + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + if self.available: + return {"device_id": self._boiler_id, "error": self.boiler_status.is_error} + return None + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.boiler_status + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def boiler_status(self): + """Return the boiler status.""" + return self.coordinator.data.boiler_status if self.coordinator.data else None + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.PROBLEM + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class MultimaticErrors(MultimaticEntity, BinarySensorEntity): + """Check if there is any error message from system.""" + + def __init__(self, coordinator: MultimaticCoordinator) -> None: + """Init.""" + super().__init__( + coordinator, + DOMAIN, + "multimatic_errors", + ) + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + if self.coordinator.data.errors: + return len(self.coordinator.data.errors) > 0 + return False + + @property + def state_attributes(self): + """Return the state attributes.""" + state_attributes = {} + if self.coordinator.data.errors: + errors = [] + for error in self.coordinator.data.errors: + errors.append( + { + "status_code": error.status_code, + "title": error.title, + "timestamp": error.timestamp, + "description": error.description, + "device_name": error.device_name, + } + ) + state_attributes["errors"] = errors + return state_attributes + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.PROBLEM + + @property + def name(self): + """Return the name of the entity.""" + return "Multimatic Errors" + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class HolidayModeSensor(MultimaticEntity, BinarySensorEntity): + """Binary sensor for holiday mode.""" + + def __init__(self, coordinator: MultimaticCoordinator) -> None: + """Init.""" + super().__init__(coordinator, DOMAIN, "multimatic_holiday") + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.coordinator.data is not None and self.coordinator.data.is_applied + + @property + def state_attributes(self): + """Return the state attributes.""" + if self.is_on: + return { + "start_date": self.coordinator.data.start_date.isoformat(), + "end_date": self.coordinator.data.end_date.isoformat(), + "temperature": self.coordinator.data.target, + } + + @property + def name(self): + """Return the name of the entity.""" + return "Multimatic holiday" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.OCCUPANCY + + +class QuickModeSensor(MultimaticEntity, BinarySensorEntity): + """Binary sensor for holiday mode.""" + + def __init__(self, coordinator: MultimaticCoordinator) -> None: + """Init.""" + super().__init__(coordinator, DOMAIN, "multimatic_quick_mode") + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.coordinator.data is not None + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = {} + if self.is_on: + attrs = {"quick_mode": self.coordinator.data.name} + if self.coordinator.data.duration: + attrs.update({ATTR_DURATION: self.coordinator.data.duration}) + return attrs + + @property + def name(self): + """Return the name of the entity.""" + return "Multimatic quick mode" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + @property + def device_class(self) -> BinarySensorDeviceClass | str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return BinarySensorDeviceClass.RUNNING diff --git a/homeassistant/components/multimatic/climate.py b/homeassistant/components/multimatic/climate.py new file mode 100644 index 00000000000000..a81c84447693c0 --- /dev/null +++ b/homeassistant/components/multimatic/climate.py @@ -0,0 +1,524 @@ +"""Interfaces with Multimatic climate.""" +from __future__ import annotations + +import abc +from collections.abc import Mapping +import logging +from typing import Any + +from pymultimatic.model import ( + ActiveFunction, + ActiveMode, + Component, + Mode, + OperatingModes, + QuickModes, + Room, + Zone, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.climate.const import ( + DOMAIN, + PRESET_AWAY, + PRESET_COMFORT, + PRESET_HOME, + PRESET_NONE, + PRESET_SLEEP, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.helpers import entity_platform + +from . import SERVICES +from .const import ( + DEFAULT_QUICK_VETO_DURATION, + DOMAIN as MULTIMATIC, + PRESET_COOLING_FOR_X_DAYS, + PRESET_COOLING_ON, + PRESET_DAY, + PRESET_HOLIDAY, + PRESET_MANUAL, + PRESET_PARTY, + PRESET_QUICK_VETO, + PRESET_SYSTEM_OFF, + ROOMS, + VENTILATION, + ZONES, +) +from .coordinator import MultimaticCoordinator +from .entities import MultimaticEntity +from .service import SERVICE_REMOVE_QUICK_VETO, SERVICE_SET_QUICK_VETO +from .utils import get_coordinator + +_LOGGER = logging.getLogger(__name__) + +_FUNCTION_TO_HVAC_ACTION: dict[ActiveFunction, str] = { + ActiveFunction.COOLING: HVACAction.COOLING, + ActiveFunction.HEATING: HVACAction.HEATING, + ActiveFunction.STANDBY: HVACAction.IDLE, +} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the multimatic climate platform.""" + climates = [] + zones_coo = get_coordinator(hass, ZONES, entry.unique_id) + rooms_coo = get_coordinator(hass, ROOMS, entry.unique_id) + ventilation_coo = get_coordinator(hass, VENTILATION, entry.unique_id) + + if zones_coo.data: + for zone in zones_coo.data: + if not zone.rbr and zone.enabled: + climates.append(ZoneClimate(zones_coo, zone, ventilation_coo.data)) + + if rooms_coo.data: + rbr_zone = next((zone for zone in zones_coo.data if zone.rbr), None) + for room in rooms_coo.data: + climates.append(RoomClimate(rooms_coo, zones_coo, room, rbr_zone)) + + _LOGGER.info("Adding %s climate entities", len(climates)) + + async_add_entities(climates) + + if len(climates) > 0: + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_REMOVE_QUICK_VETO, + SERVICES[SERVICE_REMOVE_QUICK_VETO]["schema"], + SERVICE_REMOVE_QUICK_VETO, + ) + platform.async_register_entity_service( + SERVICE_SET_QUICK_VETO, + SERVICES[SERVICE_SET_QUICK_VETO]["schema"], + SERVICE_SET_QUICK_VETO, + ) + + return True + + +class MultimaticClimate(MultimaticEntity, ClimateEntity, abc.ABC): + """Base class for climate.""" + + def __init__( + self, + coordinator: MultimaticCoordinator, + comp_id, + ): + """Initialize entity.""" + super().__init__(coordinator, DOMAIN, comp_id) + self._comp_id = comp_id + + async def set_quick_veto(self, **kwargs): + """Set quick veto, called by service.""" + temperature = kwargs.get("temperature") + duration = kwargs.get("duration", DEFAULT_QUICK_VETO_DURATION) + await self.coordinator.api.set_quick_veto(self, temperature, duration) + + async def remove_quick_veto(self, **kwargs): + """Remove quick veto, called by service.""" + await self.coordinator.api.remove_quick_veto(self) + + @property + def active_mode(self) -> ActiveMode: + """Get active mode of the climate.""" + return self.coordinator.api.get_active_mode(self.component) + + @property + @abc.abstractmethod + def component(self) -> Component: + """Return the room or the zone.""" + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.component + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.active_mode.target + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.component.temperature + + @property + def name(self) -> str | None: + """Return the name of the entity.""" + return self.component.name if self.component else None + + @property + def is_aux_heat(self) -> bool | None: + """Return true if aux heater.""" + return False + + @property + def fan_mode(self) -> str | None: + """Return the fan setting.""" + return None + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes.""" + return None + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + return None + + @property + def swing_modes(self) -> list[str] | None: + """Return the list of available swing modes.""" + return None + + def set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + + def set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + + def set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + + @property + def target_temperature_high(self) -> float | None: + """Return the highbound target temperature we try to reach.""" + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lowbound target temperature we try to reach.""" + return None + + +class RoomClimate(MultimaticClimate): + """Climate for a room.""" + + _MULTIMATIC_TO_HA: dict[Mode, list] = { + OperatingModes.AUTO: [HVACMode.AUTO, PRESET_COMFORT], + OperatingModes.OFF: [HVACMode.OFF, PRESET_NONE], + OperatingModes.QUICK_VETO: [None, PRESET_QUICK_VETO], + QuickModes.SYSTEM_OFF: [HVACMode.OFF, PRESET_SYSTEM_OFF], + QuickModes.HOLIDAY: [HVACMode.OFF, PRESET_HOLIDAY], + OperatingModes.MANUAL: [None, PRESET_MANUAL], + } + + _HA_MODE_TO_MULTIMATIC = { + HVACMode.AUTO: OperatingModes.AUTO, + HVACMode.OFF: OperatingModes.OFF, + } + + _HA_PRESET_TO_MULTIMATIC = { + PRESET_COMFORT: OperatingModes.AUTO, + PRESET_MANUAL: OperatingModes.MANUAL, + PRESET_SYSTEM_OFF: QuickModes.SYSTEM_OFF, + } + + def __init__( + self, coordinator: MultimaticCoordinator, zone_coo, room: Room, zone: Zone + ) -> None: + """Initialize entity.""" + super().__init__(coordinator, room.name) + self._zone_id = zone.id if zone else None + self._room_id = room.id + self._supported_hvac = list(RoomClimate._HA_MODE_TO_MULTIMATIC.keys()) + self._supported_presets = list(RoomClimate._HA_PRESET_TO_MULTIMATIC.keys()) + self._zone_coo = zone_coo + + @property + def device_info(self): + """Return device specific attributes.""" + devices = self.component.devices + if len(devices) == 1: # Can't link an entity to multiple devices + return { + "identifiers": {(MULTIMATIC, devices[0].sgtin)}, + "name": devices[0].name, + "manufacturer": "Vaillant", + "model": devices[0].device_type, + } + return {} + + @property + def component(self) -> Room: + """Get the component.""" + return self.coordinator.find_component(self._room_id) + + @property + def hvac_mode(self) -> str: + """Get the hvac mode based on multimatic mode.""" + hvac_mode = RoomClimate._MULTIMATIC_TO_HA[self.active_mode.current][0] + if not hvac_mode: + if ( + self.active_mode.current + in (OperatingModes.MANUAL, OperatingModes.QUICK_VETO) + and self.hvac_action == HVACAction.HEATING + ): + return HVACMode.HEAT + return hvac_mode + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available hvac operation modes.""" + return self._supported_hvac + + @property + def supported_features(self): + """Return the list of supported features.""" + return ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return Room.MIN_TARGET_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return Room.MAX_TARGET_TEMP + + @property + def zone(self): + """Return the zone the current room belongs.""" + if self._zone_coo.data and self._zone_id: + return next( + (zone for zone in self._zone_coo.data if zone.id == self._zone_id), None + ) + return None + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self.coordinator.api.set_room_target_temperature( + self, float(kwargs.get(ATTR_TEMPERATURE)) + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + mode = RoomClimate._HA_MODE_TO_MULTIMATIC[hvac_mode] + await self.coordinator.api.set_room_operating_mode(self, mode) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp. + + Requires SUPPORT_PRESET_MODE. + """ + return RoomClimate._MULTIMATIC_TO_HA[self.active_mode.current][1] + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes. + + Requires SUPPORT_PRESET_MODE. + """ + if self.active_mode.current == OperatingModes.QUICK_VETO: + return self._supported_presets + [PRESET_QUICK_VETO] + return self._supported_presets + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + mode = RoomClimate._HA_PRESET_TO_MULTIMATIC[preset_mode] + await self.coordinator.api.set_room_operating_mode(self, mode) + + @property + def hvac_action(self) -> str | None: + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + if ( + self.zone + and self.zone.active_function == ActiveFunction.HEATING + and self.component.temperature < self.active_mode.target + ): + return _FUNCTION_TO_HVAC_ACTION[ActiveFunction.HEATING] + return _FUNCTION_TO_HVAC_ACTION[ActiveFunction.STANDBY] + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + humidity = self.component.humidity + return int(humidity) if humidity is not None else None + + +class ZoneClimate(MultimaticClimate): + """Climate for a zone.""" + + _MULTIMATIC_TO_HA: dict[Mode, list] = { + OperatingModes.AUTO: [HVACMode.AUTO, PRESET_COMFORT], + OperatingModes.DAY: [None, PRESET_DAY], + OperatingModes.NIGHT: [None, PRESET_SLEEP], + OperatingModes.OFF: [HVACMode.OFF, PRESET_NONE], + OperatingModes.ON: [None, PRESET_COOLING_ON], + OperatingModes.QUICK_VETO: [None, PRESET_QUICK_VETO], + QuickModes.ONE_DAY_AT_HOME: [HVACMode.AUTO, PRESET_HOME], + QuickModes.PARTY: [None, PRESET_PARTY], + QuickModes.VENTILATION_BOOST: [HVACMode.FAN_ONLY, PRESET_NONE], + QuickModes.ONE_DAY_AWAY: [HVACMode.OFF, PRESET_AWAY], + QuickModes.SYSTEM_OFF: [HVACMode.OFF, PRESET_SYSTEM_OFF], + QuickModes.HOLIDAY: [HVACMode.OFF, PRESET_HOLIDAY], + QuickModes.COOLING_FOR_X_DAYS: [None, PRESET_COOLING_FOR_X_DAYS], + } + + _HA_MODE_TO_MULTIMATIC = { + HVACMode.AUTO: OperatingModes.AUTO, + HVACMode.OFF: OperatingModes.OFF, + HVACMode.FAN_ONLY: QuickModes.VENTILATION_BOOST, + HVACMode.COOL: QuickModes.COOLING_FOR_X_DAYS, + } + + _HA_PRESET_TO_MULTIMATIC = { + PRESET_COMFORT: OperatingModes.AUTO, + PRESET_DAY: OperatingModes.DAY, + PRESET_SLEEP: OperatingModes.NIGHT, + PRESET_COOLING_ON: OperatingModes.ON, + PRESET_HOME: QuickModes.ONE_DAY_AT_HOME, + PRESET_PARTY: QuickModes.PARTY, + PRESET_AWAY: QuickModes.ONE_DAY_AWAY, + PRESET_SYSTEM_OFF: QuickModes.SYSTEM_OFF, + PRESET_COOLING_FOR_X_DAYS: QuickModes.COOLING_FOR_X_DAYS, + } + + def __init__( + self, coordinator: MultimaticCoordinator, zone: Zone, ventilation + ) -> None: + """Initialize entity.""" + super().__init__(coordinator, zone.id) + + self._supported_hvac = list(ZoneClimate._HA_MODE_TO_MULTIMATIC.keys()) + self._supported_presets = list(ZoneClimate._HA_PRESET_TO_MULTIMATIC.keys()) + + if not zone.cooling: + self._supported_presets.remove(PRESET_COOLING_ON) + self._supported_presets.remove(PRESET_COOLING_FOR_X_DAYS) + self._supported_hvac.remove(HVACMode.COOL) + + if not ventilation: + self._supported_hvac.remove(HVACMode.FAN_ONLY) + + self._zone_id = zone.id + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + attr = {} + if self.active_mode.current == QuickModes.COOLING_FOR_X_DAYS: + attr.update( + {"cooling_for_x_days_duration": self.active_mode.current.duration} + ) + return attr + + @property + def component(self) -> Zone: + """Return the zone.""" + return self.coordinator.find_component(self._zone_id) + + @property + def hvac_mode(self): + """Get the hvac mode based on multimatic mode.""" + current_mode = self.active_mode.current + hvac_mode = ZoneClimate._MULTIMATIC_TO_HA[current_mode][0] + if not hvac_mode: + if ( + current_mode + in [ + OperatingModes.DAY, + OperatingModes.NIGHT, + QuickModes.PARTY, + OperatingModes.QUICK_VETO, + ] + and self.hvac_action == HVACAction.HEATING + ): + return HVACMode.HEAT + if ( + self.preset_mode in (PRESET_COOLING_ON, PRESET_COOLING_FOR_X_DAYS) + and self.hvac_action == HVACAction.COOLING + ): + return HVACMode.COOL + return hvac_mode if hvac_mode else HVACMode.OFF + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available hvac operation modes.""" + return self._supported_hvac + + @property + def supported_features(self): + """Return the list of supported features.""" + return ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return Zone.MIN_TARGET_HEATING_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return Zone.MAX_TARGET_TEMP + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.active_mode.target + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + + if temp and temp != self.active_mode.target: + _LOGGER.debug("Setting target temp to %s", temp) + await self.coordinator.api.set_zone_target_temperature(self, temp) + else: + _LOGGER.debug("Nothing to do") + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + mode = ZoneClimate._HA_MODE_TO_MULTIMATIC[hvac_mode] + await self.coordinator.api.set_zone_operating_mode(self, mode) + + @property + def hvac_action(self) -> str | None: + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + return _FUNCTION_TO_HVAC_ACTION.get(self.component.active_function) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + return ZoneClimate._MULTIMATIC_TO_HA[self.active_mode.current][1] + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes.""" + if self.active_mode.current == OperatingModes.QUICK_VETO: + return self._supported_presets + [PRESET_QUICK_VETO] + return self._supported_presets + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + mode = ZoneClimate._HA_PRESET_TO_MULTIMATIC[preset_mode] + await self.coordinator.api.set_zone_operating_mode(self, mode) diff --git a/homeassistant/components/multimatic/config_flow.py b/homeassistant/components/multimatic/config_flow.py new file mode 100644 index 00000000000000..4d5b84dbff70bf --- /dev/null +++ b/homeassistant/components/multimatic/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for multimatic integration.""" +import logging + +from pymultimatic.api import ApiError +from pymultimatic.systemmanager import SystemManager +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_create_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_SERIAL_NUMBER, DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SERIAL_NUMBER): str, + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + await validate_authentication(hass, data[CONF_USERNAME], data[CONF_PASSWORD]) + + return {"title": "Multimatic"} + + +async def validate_authentication(hass, username, password): + """Ensure provided credentials are working.""" + try: + if not await SystemManager( + username, password, async_create_clientsession(hass) + ).login(True): + raise InvalidAuth + except ApiError as err: + _LOGGER.error( + "Unable to authenticate: %s, status: %s, response: %s", + err.message, + err.status, + err.response, + ) + raise InvalidAuth from err + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for multimatic.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return MultimaticOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class MultimaticOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): cv.positive_int + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/multimatic/const.py b/homeassistant/components/multimatic/const.py new file mode 100644 index 00000000000000..ce63f53c162e3e --- /dev/null +++ b/homeassistant/components/multimatic/const.py @@ -0,0 +1,78 @@ +"""multimatic integration constants.""" + +from __future__ import annotations + +from datetime import timedelta + +DOMAIN = "multimatic" +ENTITIES = "entities" + +# list of platforms into entity are created +PLATFORMS = ["binary_sensor", "sensor", "water_heater", "climate", "fan"] + +# climate custom presets +PRESET_DAY = "day" +PRESET_COOLING_ON = "cooling_on" +PRESET_MANUAL = "manual" +PRESET_SYSTEM_OFF = "system_off" +PRESET_PARTY = "party" +PRESET_HOLIDAY = "holiday" +PRESET_QUICK_VETO = "quick_veto" +PRESET_COOLING_FOR_X_DAYS = "cooling_for_x_days" + + +# default values for configuration +DEFAULT_EMPTY = "" +DEFAULT_SCAN_INTERVAL = 2 +DEFAULT_QUICK_VETO_DURATION = 3 * 60 +DEFAULT_SMART_PHONE_ID = "homeassistant" + +# max and min values for quick veto +MIN_QUICK_VETO_DURATION = 0.5 * 60 +MAX_QUICK_VETO_DURATION = 24 * 60 + +# configuration keys +CONF_QUICK_VETO_DURATION = "quick_veto_duration" +CONF_SERIAL_NUMBER = "serial_number" + +# constants for states_attributes +ATTR_QUICK_MODE = "quick_mode" +ATTR_START_DATE = "start_date" +ATTR_END_DATE = "end_date" +ATTR_TEMPERATURE = "temperature" +ATTR_DURATION = "duration" +ATTR_LEVEL = "level" +ATTR_DATE_TIME = "datetime" + +SERVICES_HANDLER = "services_handler" + +REFRESH_EVENT = "multimatic_refresh_event" + +# Update api keys +ZONES = "zones" +ROOMS = "rooms" +DHW = "dhw" +REPORTS = "live_reports" +OUTDOOR_TEMP = "outdoor_temperature" +VENTILATION = "ventilation" +QUICK_MODE = "quick_mode" +HOLIDAY_MODE = "holiday_mode" +HVAC_STATUS = "hvac_status" +FACILITY_DETAIL = "facility_detail" +GATEWAY = "gateway" +EMF_REPORTS = "emf_reports" +COORDINATORS = "coordinators" +COORDINATOR_LIST: dict[str, timedelta | None] = { + ZONES: None, + ROOMS: None, + DHW: None, + REPORTS: None, + OUTDOOR_TEMP: None, + VENTILATION: None, + QUICK_MODE: None, + HOLIDAY_MODE: None, + HVAC_STATUS: None, + FACILITY_DETAIL: timedelta(days=1), + GATEWAY: timedelta(days=1), + EMF_REPORTS: None, +} diff --git a/homeassistant/components/multimatic/coordinator.py b/homeassistant/components/multimatic/coordinator.py new file mode 100644 index 00000000000000..2a5450217972ec --- /dev/null +++ b/homeassistant/components/multimatic/coordinator.py @@ -0,0 +1,562 @@ +"""Api hub and integration data.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pymultimatic.api import ApiError +from pymultimatic.model import ( + Circulation, + Component, + HolidayMode, + HotWater, + Mode, + OperatingModes, + QuickMode, + QuickModes, + QuickVeto, + Room, + Ventilation, + Zone, + ZoneCooling, + ZoneHeating, +) +import pymultimatic.systemmanager +import pymultimatic.utils as multimatic_utils + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_SERIAL_NUMBER, + DEFAULT_QUICK_VETO_DURATION, + HOLIDAY_MODE, + QUICK_MODE, + REFRESH_EVENT, +) +from .utils import ( + holiday_mode_from_json, + holiday_mode_to_json, + quick_mode_from_json, + quick_mode_to_json, +) + +_LOGGER = logging.getLogger(__name__) + + +class MultimaticApi: + """Utility to interact with multimatic API.""" + + def __init__(self, hass, entry: ConfigEntry): + """Init.""" + + self.serial = entry.data.get(CONF_SERIAL_NUMBER) + self.fixed_serial = self.serial is not None + + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + self._manager = pymultimatic.systemmanager.SystemManager( + user=username, + password=password, + session=async_create_clientsession(hass), + serial=self.serial, + ) + + self._quick_mode: QuickMode | None = None + self._holiday_mode: HolidayMode | None = None + self._hass = hass + + async def login(self, force): + """Login to the API.""" + return await self._manager.login(force) + + async def logout(self): + """Logout from te API.""" + return await self._manager.logout() + + async def get_gateway(self): + """Get the gateway.""" + return await self._manager.get_gateway() + + async def get_facility_detail(self): + """Get facility detail.""" + detail = await self._manager.get_facility_detail(self.serial) + if detail and not self.fixed_serial and not self.serial: + self.serial = detail.serial_number + return detail + + async def get_zones(self): + """Get the zones.""" + _LOGGER.debug("Will get zones") + return await self._manager.get_zones() + + async def get_outdoor_temperature(self): + """Get outdoor temperature.""" + _LOGGER.debug("Will get outdoor temperature") + return await self._manager.get_outdoor_temperature() + + async def get_rooms(self): + """Get rooms.""" + _LOGGER.debug("Will get rooms") + return await self._manager.get_rooms() + + async def get_ventilation(self): + """Get ventilation.""" + _LOGGER.debug("Will get ventilation") + return await self._manager.get_ventilation() + + async def get_dhw(self): + """Get domestic hot water. + + There is a 2 queries here, one to ge the dhw and a second one to get the current temperature if + there is a water tank. + """ + _LOGGER.debug("Will get dhw") + dhw = await self._manager.get_dhw() + if dhw and dhw.hotwater and dhw.hotwater.time_program: + _LOGGER.debug("Will get temperature report") + report = await self._manager.get_live_report( + "DomesticHotWaterTankTemperature", "Control_DHW" + ) + dhw.hotwater.temperature = report.value if report else None + return dhw + + async def get_live_reports(self): + """Get reports.""" + _LOGGER.debug("Will get reports") + return await self._manager.get_live_reports() + + async def get_quick_mode(self): + """Get quick modes.""" + _LOGGER.debug("Will get quick_mode") + self._quick_mode = await self._manager.get_quick_mode() + return self._quick_mode + + async def get_holiday_mode(self): + """Get holiday mode.""" + _LOGGER.debug("Will get holiday_mode") + self._holiday_mode = await self._manager.get_holiday_mode() + return self._holiday_mode + + async def get_hvac_status(self): + """Get the status of the HVAC.""" + _LOGGER.debug("Will get hvac status") + return await self._manager.get_hvac_status() + + async def get_emf_reports(self): + """Get emf reports.""" + _LOGGER.debug("Will get emf reports") + return await self._manager.get_emf_devices() + + async def request_hvac_update(self): + """Request is not on the classic update since it won't fetch data. + + The request update will trigger something at multimatic API and it will + ask data to your system. + """ + try: + _LOGGER.debug("Will request_hvac_update") + await self._manager.request_hvac_update() + except ApiError as err: + if err.status >= 500: + raise + _LOGGER.warning("Request_hvac_update is done too often", exc_info=True) + + def get_active_mode(self, comp: Component): + """Get active mode for room, zone, circulation, ventilaton or hotwater, no IO.""" + return multimatic_utils.active_mode_for( + comp, self._holiday_mode, self._quick_mode + ) + + async def set_hot_water_target_temperature(self, entity, target_temp): + """Set hot water target temperature. + + * If there is a quick mode that impact dhw running on or holiday mode, + remove it. + + * If dhw is ON or AUTO, modify the target temperature + + * If dhw is OFF, change to ON and set target temperature + """ + + hotwater = entity.component + touch_system = await self._remove_quick_mode_or_holiday(entity) + current_mode = self.get_active_mode(hotwater).current + + if current_mode == OperatingModes.OFF: + await self._manager.set_hot_water_operating_mode( + hotwater.id, OperatingModes.ON + ) + hotwater.operating_mode = OperatingModes.ON + await self._manager.set_hot_water_setpoint_temperature(hotwater.id, target_temp) + hotwater.target_high = target_temp + + await self._refresh(touch_system, entity) + + async def set_room_target_temperature(self, entity, target_temp): + """Set target temperature for a room. + + * If there is a quick mode that impact room running on or holiday mode, + remove it. + + * If the room is in MANUAL mode, simply modify the target temperature. + + * if the room is not in MANUAL mode, create à quick veto. + """ + + touch_system = await self._remove_quick_mode_or_holiday(entity) + + room = entity.component + current_mode = self.get_active_mode(room).current + + if current_mode == OperatingModes.MANUAL: + await self._manager.set_room_setpoint_temperature(room.id, target_temp) + room.target_temperature = target_temp + else: + if current_mode == OperatingModes.QUICK_VETO: + await self._manager.remove_room_quick_veto(room.id) + + qveto = QuickVeto(DEFAULT_QUICK_VETO_DURATION, target_temp) + await self._manager.set_room_quick_veto(room.id, qveto) + room.quick_veto = qveto + + await self._refresh(touch_system, entity) + + async def set_zone_target_temperature(self, entity, target_temp): + """Set target temperature for a zone. + + * If there is a quick mode related to zone running or holiday mode, + remove it. + + * If quick veto running on, remove it and create a new one with the + new target temp + + * If any other mode, create a quick veto + """ + + touch_system = await self._remove_quick_mode_or_holiday(entity) + zone = entity.component + + current_mode = self.get_active_mode(zone).current + + if current_mode == OperatingModes.QUICK_VETO: + await self._manager.remove_zone_quick_veto(zone.id) + + veto = QuickVeto(None, target_temp) + await self._manager.set_zone_quick_veto(zone.id, veto) + zone.quick_veto = veto + + await self._refresh(touch_system, entity) + + async def set_hot_water_operating_mode(self, entity, mode): + """Set hot water operation mode. + + If there is a quick mode that impact hot warter running on or holiday + mode, remove it. + """ + hotwater = entity.component + touch_system = await self._remove_quick_mode_or_holiday(entity) + + await self._manager.set_hot_water_operating_mode(hotwater.id, mode) + hotwater.operating_mode = mode + + await self._refresh(touch_system, entity) + + async def set_room_operating_mode(self, entity, mode): + """Set room operation mode. + + If there is a quick mode that impact room running on or holiday mode, + remove it. + """ + touch_system = await self._remove_quick_mode_or_holiday(entity) + room = entity.component + if room.quick_veto is not None: + await self._manager.remove_room_quick_veto(room.id) + room.quick_veto = None + + if isinstance(mode, QuickMode): + await self._hard_set_quick_mode(mode) + self._quick_mode = mode + touch_system = True + else: + await self._manager.set_room_operating_mode(room.id, mode) + room.operating_mode = mode + + await self._refresh(touch_system, entity) + + async def set_zone_operating_mode(self, entity, mode): + """Set zone operation mode. + + If there is a quick mode that impact zone running on or holiday mode, + remove it. + """ + touch_system = await self._remove_quick_mode_or_holiday(entity) + zone = entity.component + + if zone.quick_veto is not None: + await self._manager.remove_zone_quick_veto(zone.id) + zone.quick_veto = None + + if isinstance(mode, QuickMode): + await self._hard_set_quick_mode(mode) + self._quick_mode = mode + touch_system = True + else: + if zone.heating and mode in ZoneHeating.MODES: + await self._manager.set_zone_heating_operating_mode(zone.id, mode) + zone.heating.operating_mode = mode + if zone.cooling and mode in ZoneCooling.MODES: + await self._manager.set_zone_cooling_operating_mode(zone.id, mode) + zone.cooling.operating_mode = mode + + await self._refresh(touch_system, entity) + + async def remove_quick_mode(self, entity=None): + """Remove quick mode. + + If entity is not None, only remove if the quick mode applies to the + given entity. + """ + if await self._remove_quick_mode_no_refresh(entity): + await self._refresh_entities() + + async def remove_holiday_mode(self): + """Remove holiday mode.""" + if await self._remove_holiday_mode_no_refresh(): + await self._refresh_entities() + + async def set_holiday_mode(self, start_date, end_date, temperature): + """Set holiday mode.""" + await self._manager.set_holiday_mode(start_date, end_date, temperature) + self._holiday_mode = HolidayMode(True, start_date, end_date, temperature) + await self._refresh_entities() + + async def set_quick_mode(self, mode, duration): + """Set quick mode (remove previous one).""" + await self._remove_quick_mode_no_refresh() + self._quick_mode = await self._hard_set_quick_mode(mode, duration) + await self._refresh_entities() + + async def set_quick_veto(self, entity, temperature, duration=None): + """Set quick veto for the given entity.""" + comp = entity.component + + q_duration = duration if duration else DEFAULT_QUICK_VETO_DURATION + qveto = QuickVeto(q_duration, temperature) + + if isinstance(comp, Zone): + if comp.quick_veto: + await self._manager.remove_zone_quick_veto(comp.id) + await self._manager.set_zone_quick_veto(comp.id, qveto) + else: + if comp.quick_veto: + await self._manager.remove_room_quick_veto(comp.id) + await self._manager.set_room_quick_veto(comp.id, qveto) + comp.quick_veto = qveto + await self._refresh(False, entity) + + async def remove_quick_veto(self, entity): + """Remove quick veto for the given entity.""" + comp = entity.component + + if comp and comp.quick_veto: + if isinstance(comp, Zone): + await self._manager.remove_zone_quick_veto(comp.id) + else: + await self._manager.remove_room_quick_veto(comp.id) + comp.quick_veto = None + await self._refresh(False, entity) + + async def set_fan_operating_mode(self, entity, mode: Mode): + """Set fan operating mode.""" + + touch_system = await self._remove_quick_mode_or_holiday(entity) + + if isinstance(mode, QuickMode): + await self._hard_set_quick_mode(mode) + self._quick_mode = mode + touch_system = True + else: + await self._manager.set_ventilation_operating_mode( + entity.component.id, mode + ) + entity.component.operating_mode = mode + await self._refresh(touch_system, entity) + + async def set_fan_day_level(self, entity, level): + """Set fan day level.""" + await self._manager.set_ventilation_day_level(entity.component.id, level) + + async def set_fan_night_level(self, entity, level): + """Set fan night level.""" + await self._manager.set_ventilation_night_level(entity.component.id, level) + + async def set_datetime(self, datetime): + """Set datetime.""" + await self._manager.set_datetime(datetime) + + async def _remove_quick_mode_no_refresh(self, entity=None): + removed = False + + qmode = self._quick_mode + if entity and qmode: + if qmode.is_for(entity.component): + await self._hard_remove_quick_mode() + removed = True + else: # coming from service call + await self._hard_remove_quick_mode() + removed = True + + return removed + + async def _hard_remove_quick_mode(self): + await self._manager.remove_quick_mode() + self._quick_mode = None + + async def _hard_set_quick_mode( + self, mode: str | QuickMode, duration: int | None = None + ) -> QuickMode: + new_mode: QuickMode + + if isinstance(mode, QuickMode): + new_mode = mode + if ( + mode.name == QuickModes.COOLING_FOR_X_DAYS.name + and mode.duration is None + ): + new_mode = QuickModes.get(mode.name, 1) + else: + new_duration = duration + if mode == QuickModes.COOLING_FOR_X_DAYS.name and duration is None: + new_duration = 1 + new_mode = QuickModes.get(mode, new_duration) + + await self._manager.set_quick_mode(new_mode) + return new_mode + + async def _remove_holiday_mode_no_refresh(self): + await self._manager.remove_holiday_mode() + self._holiday_mode = HolidayMode(False) + return True + + async def _remove_quick_mode_or_holiday(self, entity): + return ( + await self._remove_holiday_mode_no_refresh() + | await self._remove_quick_mode_no_refresh(entity) + ) + + async def _refresh_entities(self): + """Fetch multimatic data and force refresh of all listening entities.""" + data = { + QUICK_MODE: quick_mode_to_json(self._quick_mode), + HOLIDAY_MODE: holiday_mode_to_json(self._holiday_mode), + } + self._hass.bus.async_fire(REFRESH_EVENT, data) + + async def _refresh(self, touch_system, entity): + if touch_system: + await self._refresh_entities() + entity.async_schedule_update_ha_state(True) + + +class MultimaticCoordinator(DataUpdateCoordinator): + """Multimatic coordinator.""" + + def __init__( + self, + hass, + name, + api: MultimaticApi, + method: str, + update_interval: timedelta | None, + ): + """Init.""" + + self._api_listeners: set = set() + self._method = method + self.api: MultimaticApi = api + + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=update_interval, + update_method=self._first_fetch_data, + ) + + self._remove_listener = self.hass.bus.async_listen( + REFRESH_EVENT, self._handle_event + ) + + def find_component( + self, comp_id + ) -> Room | Zone | Ventilation | HotWater | Circulation | None: + """Find component by its id.""" + for comp in self.data: + if comp.id == comp_id: + return comp + return None + + def remove_api_listener(self, unique_id: str): + """Remove entity from listening to the api.""" + if unique_id in self._api_listeners: + self.logger.debug("Removing %s from %s", unique_id, self._method) + self._api_listeners.remove(unique_id) + + def add_api_listener(self, unique_id: str): + """Make an entity listen to API.""" + if unique_id not in self._api_listeners: + self.logger.debug("Adding %s to key %s", unique_id, self._method) + self._api_listeners.add(unique_id) + + async def _handle_event(self, event): + if isinstance(self.data, QuickMode): + quick_mode = quick_mode_from_json(event.data.get(QUICK_MODE)) + self.async_set_updated_data(quick_mode) + elif isinstance(self.data, HolidayMode): + holiday_mode = holiday_mode_from_json(event.data.get(HOLIDAY_MODE)) + self.async_set_updated_data(holiday_mode) + else: + self.async_set_updated_data( + self.data + ) # Fake refresh for climates and water heater and fan + + async def _fetch_data(self): + try: + self.logger.debug("calling %s", self._method) + return await getattr(self.api, self._method)() + except ApiError as err: + if err.status == 401: + await self._safe_logout() + raise + + async def _fetch_data_if_needed(self): + if self._api_listeners and len(self._api_listeners) > 0: + return await self._fetch_data() + + async def _first_fetch_data(self): + try: + result = await self._fetch_data() + self.update_method = self._fetch_data_if_needed + return result + except ApiError as err: + if err.status in (400, 409): + self.update_method = self._fetch_data_if_needed + _LOGGER.debug( + "Received %s %s when calling %s for the first time", + err.response, + err.message, + self.name, + exc_info=True, + ) + return None + raise + + async def _safe_logout(self): + try: + await self.api.logout() + except ApiError: + self.logger.debug("Error during logout", exc_info=True) diff --git a/homeassistant/components/multimatic/entities.py b/homeassistant/components/multimatic/entities.py new file mode 100644 index 00000000000000..794dedba40b40b --- /dev/null +++ b/homeassistant/components/multimatic/entities.py @@ -0,0 +1,53 @@ +"""Common entities.""" +from __future__ import annotations + +from abc import ABC +import logging + +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .const import DOMAIN as MULTIMATIC +from .coordinator import MultimaticCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class MultimaticEntity(CoordinatorEntity, ABC): + """Define base class for multimatic entities.""" + + coordinator: MultimaticCoordinator + + def __init__(self, coordinator: MultimaticCoordinator, domain, device_id): + """Initialize entity.""" + super().__init__(coordinator) + + id_part = slugify( + device_id + + (f"_{coordinator.api.serial}" if coordinator.api.fixed_serial else "") + ) + + self.entity_id = f"{domain}.{id_part}" + self._unique_id = slugify(f"{MULTIMATIC}_{coordinator.api.serial}_{device_id}") + self._remove_listener = None + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + await super().async_added_to_hass() + _LOGGER.debug("%s added", self.entity_id) + self.coordinator.add_api_listener(self.unique_id) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + self.coordinator.remove_api_listener(self.unique_id) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data diff --git a/homeassistant/components/multimatic/fan.py b/homeassistant/components/multimatic/fan.py new file mode 100644 index 00000000000000..05bc780477c9c8 --- /dev/null +++ b/homeassistant/components/multimatic/fan.py @@ -0,0 +1,149 @@ +"""Interfaces with Multimatic fan.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pymultimatic.model import OperatingModes, QuickModes + +from homeassistant.components.fan import DOMAIN, FanEntity, FanEntityFeature +from homeassistant.helpers import entity_platform + +from .const import ATTR_LEVEL, VENTILATION +from .coordinator import MultimaticCoordinator +from .entities import MultimaticEntity +from .service import ( + SERVICE_SET_VENTILATION_DAY_LEVEL, + SERVICE_SET_VENTILATION_NIGHT_LEVEL, + SERVICES, +) +from .utils import get_coordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the multimatic fan platform.""" + + coordinator = get_coordinator(hass, VENTILATION, entry.unique_id) + + if coordinator.data: + _LOGGER.debug("Adding fan entity") + async_add_entities([MultimaticFan(coordinator)]) + + _LOGGER.debug("Adding fan services") + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_SET_VENTILATION_DAY_LEVEL, + SERVICES[SERVICE_SET_VENTILATION_DAY_LEVEL]["schema"], + SERVICE_SET_VENTILATION_DAY_LEVEL, + ) + platform.async_register_entity_service( + SERVICE_SET_VENTILATION_NIGHT_LEVEL, + SERVICES[SERVICE_SET_VENTILATION_NIGHT_LEVEL]["schema"], + SERVICE_SET_VENTILATION_NIGHT_LEVEL, + ) + + +class MultimaticFan(MultimaticEntity, FanEntity): + """Representation of a multimatic fan.""" + + def __init__(self, coordinator: MultimaticCoordinator) -> None: + """Initialize entity.""" + + super().__init__( + coordinator, + DOMAIN, + coordinator.data.id, + ) + + self._preset_modes = [ + OperatingModes.AUTO.name, + OperatingModes.DAY.name, + OperatingModes.NIGHT.name, + ] + + @property + def component(self): + """Return the ventilation.""" + return self.coordinator.data + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self.component.name if self.component else None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + return await self.coordinator.api.set_fan_operating_mode( + self, OperatingModes.get(preset_mode.upper()) + ) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs, + ) -> None: + """Turn on the fan.""" + if preset_mode: + mode = OperatingModes.get(preset_mode.upper()) + else: + mode = OperatingModes.AUTO + return await self.coordinator.api.set_fan_operating_mode(self, mode) + + async def async_turn_off(self, **kwargs: Any): + """Turn on the fan.""" + return await self.coordinator.api.set_fan_operating_mode( + self, OperatingModes.NIGHT + ) + + @property + def is_on(self): + """Return true if the entity is on.""" + return self.active_mode.current != OperatingModes.NIGHT + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return FanEntityFeature.PRESET_MODE + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., auto, smart, interval, favorite.""" + return self.active_mode.current.name + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes. + + Requires SUPPORT_SET_SPEED. + """ + if self.active_mode.current == QuickModes.VENTILATION_BOOST: + return self._preset_modes + [QuickModes.VENTILATION_BOOST.name] + return self._preset_modes + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.component + + @property + def active_mode(self): + """Return the active mode.""" + return self.coordinator.api.get_active_mode(self.component) + + async def set_ventilation_day_level(self, **kwargs): + """Service method to set day level.""" + await self.coordinator.api.set_fan_day_level(self, kwargs.get(ATTR_LEVEL)) + + async def set_ventilation_night_level(self, **kwargs): + """Service method to set night level.""" + await self.coordinator.api.set_fan_night_level(self, kwargs.get(ATTR_LEVEL)) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + return {"speed": self.active_mode.target} diff --git a/homeassistant/components/multimatic/manifest.json b/homeassistant/components/multimatic/manifest.json new file mode 100644 index 00000000000000..b1f4ad894b337b --- /dev/null +++ b/homeassistant/components/multimatic/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "multimatic", + "name": "Multimatic", + "config_flow": true, + "documentation": "https://github.com/thomasgermain/vaillant-component", + "requirements": ["pymultimatic==0.6.11"], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": ["@thomasgermain"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/multimatic/sensor.py b/homeassistant/components/multimatic/sensor.py new file mode 100644 index 00000000000000..4a654c0778f4b2 --- /dev/null +++ b/homeassistant/components/multimatic/sensor.py @@ -0,0 +1,237 @@ +"""Interfaces with multimatic sensors.""" + +from __future__ import annotations + +import logging + +from pymultimatic.model import EmfReport, Report + +from homeassistant.components.sensor import ( + DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfTemperature +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.typing import StateType + +from .const import EMF_REPORTS, OUTDOOR_TEMP, REPORTS +from .coordinator import MultimaticCoordinator +from .entities import MultimaticEntity +from .utils import get_coordinator + +_LOGGER = logging.getLogger(__name__) + +UNIT_TO_DEVICE_CLASS = { + "bar": SensorDeviceClass.PRESSURE, + "ppm": SensorDeviceClass.CO2, + "Wh": SensorDeviceClass.ENERGY, + "°C": SensorDeviceClass.TEMPERATURE, +} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the multimatic sensors.""" + sensors = [] + outdoor_temp_coo = get_coordinator(hass, OUTDOOR_TEMP, entry.unique_id) + reports_coo = get_coordinator(hass, REPORTS, entry.unique_id) + emf_reports_coo = get_coordinator(hass, EMF_REPORTS, entry.unique_id) + + if outdoor_temp_coo.data: + sensors.append(OutdoorTemperatureSensor(outdoor_temp_coo)) + + if reports_coo.data: + sensors.extend(ReportSensor(reports_coo, report) for report in reports_coo.data) + + if emf_reports_coo.data: + sensors.extend( + EmfReportSensor(emf_reports_coo, report) for report in emf_reports_coo.data + ) + + _LOGGER.info("Adding %s sensor entities", len(sensors)) + + async_add_entities(sensors) + return True + + +class OutdoorTemperatureSensor(MultimaticEntity, SensorEntity): + """Outdoor temperature sensor.""" + + def __init__(self, coordinator: MultimaticCoordinator) -> None: + """Initialize entity.""" + super().__init__(coordinator, DOMAIN, "outdoor_temperature") + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + return self.coordinator.data + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.coordinator.data is not None + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of this entity, if any.""" + return UnitOfTemperature.CELSIUS + + @property + def name(self) -> str: + """Return the name of the entity.""" + return "Outdoor temperature" + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return SensorDeviceClass.TEMPERATURE + + @property + def state_class(self) -> str | None: + """Return the state class of this entity.""" + return SensorStateClass.MEASUREMENT + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class ReportSensor(MultimaticEntity, SensorEntity): + """Report sensor.""" + + def __init__(self, coordinator: MultimaticCoordinator, report: Report) -> None: + """Init entity.""" + MultimaticEntity.__init__(self, coordinator, DOMAIN, report.id) + self._report_id = report.id + self._unit = report.unit + self._name = report.name + self._class = UNIT_TO_DEVICE_CLASS.get(report.unit, None) + self._device_name = report.device_name + self._device_id = report.device_id + + @property + def report(self): + """Get the current report based on the id.""" + return next( + ( + report + for report in self.coordinator.data + if report.id == self._report_id + ), + None, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + return self.report.value + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.report is not None + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of this entity, if any.""" + return self._unit + + @property + def device_info(self): + """Return device specific attributes.""" + return { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device_name, + "manufacturer": "Vaillant", + "model": self.report.device_id, + } + + @property + def state_class(self) -> str | None: + """Return the state class of this entity, from STATE_CLASSES, if any.""" + return SensorStateClass.MEASUREMENT + + @property + def device_class(self) -> str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._class + + @property + def name(self) -> str | None: + """Return the name of the entity.""" + return self._name + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC + + +class EmfReportSensor(MultimaticEntity, SensorEntity): + """Emf Report sensor.""" + + def __init__(self, coordinator: MultimaticCoordinator, report: EmfReport) -> None: + """Init entity.""" + self._device_id = f"{report.device_id}_{report.function}_{report.energyType}" + self._name = f"{report.device_name} {report.function} {report.energyType}" + MultimaticEntity.__init__(self, coordinator, DOMAIN, self._device_id) + + @property + def report(self): + """Get the current report based on the id.""" + return next( + ( + report + for report in self.coordinator.data + if f"{report.device_id}_{report.function}_{report.energyType}" + == self._device_id + ), + None, + ) + + @property + def native_value(self): + """Return the state of the entity.""" + return self.report.value + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.report is not None + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of this entity, if any.""" + return UnitOfEnergy.WATT_HOUR + + @property + def device_info(self): + """Return device specific attributes.""" + return { + "identifiers": {(DOMAIN, self.report.device_id)}, + "name": self.report.device_name, + "manufacturer": "Vaillant", + "model": self.report.device_id, + } + + @property + def device_class(self) -> str | None: + """Return the class of this device, from component DEVICE_CLASSES.""" + return SensorDeviceClass.ENERGY + + @property + def name(self) -> str | None: + """Return the name of the entity.""" + return self._name + + @property + def state_class(self) -> str: + """Return the state class of this entity.""" + return SensorStateClass.TOTAL_INCREASING + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + return EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/multimatic/service.py b/homeassistant/components/multimatic/service.py new file mode 100644 index 00000000000000..8cfa4a9c96b755 --- /dev/null +++ b/homeassistant/components/multimatic/service.py @@ -0,0 +1,169 @@ +"""multimatic services.""" +import datetime +import logging + +from pymultimatic.model import QuickMode, QuickModes +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv +from homeassistant.util.dt import parse_date + +from .const import ( + ATTR_DATE_TIME, + ATTR_DURATION, + ATTR_END_DATE, + ATTR_LEVEL, + ATTR_QUICK_MODE, + ATTR_START_DATE, + ATTR_TEMPERATURE, +) +from .coordinator import MultimaticApi + +_LOGGER = logging.getLogger(__name__) + +QUICK_MODES_LIST = [ + v.name for v in QuickModes.__dict__.values() if isinstance(v, QuickMode) +] + +SERVICE_REMOVE_QUICK_MODE = "remove_quick_mode" +SERVICE_REMOVE_HOLIDAY_MODE = "remove_holiday_mode" +SERVICE_SET_QUICK_MODE = "set_quick_mode" +SERVICE_SET_HOLIDAY_MODE = "set_holiday_mode" +SERVICE_SET_QUICK_VETO = "set_quick_veto" +SERVICE_REMOVE_QUICK_VETO = "remove_quick_veto" +SERVICE_REQUEST_HVAC_UPDATE = "request_hvac_update" +SERVICE_SET_VENTILATION_DAY_LEVEL = "set_ventilation_day_level" +SERVICE_SET_VENTILATION_NIGHT_LEVEL = "set_ventilation_night_level" +SERVICE_SET_DATETIME = "set_datetime" + +SERVICE_REMOVE_QUICK_MODE_SCHEMA = vol.Schema({}) +SERVICE_REMOVE_HOLIDAY_MODE_SCHEMA = vol.Schema({}) +SERVICE_REMOVE_QUICK_VETO_SCHEMA = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): vol.All(vol.Coerce(str))} +) +SERVICE_SET_QUICK_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_QUICK_MODE): vol.All( + vol.Coerce(str), vol.In(QUICK_MODES_LIST) + ), + vol.Optional(ATTR_DURATION): vol.All(vol.Coerce(int), vol.Clamp(min=1)), + } +) +SERVICE_SET_HOLIDAY_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_START_DATE): vol.All(vol.Coerce(str)), + vol.Required(ATTR_END_DATE): vol.All(vol.Coerce(str)), + vol.Required(ATTR_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Clamp(min=5, max=30) + ), + } +) +SERVICE_SET_QUICK_VETO_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): vol.All(vol.Coerce(str)), + vol.Required(ATTR_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Clamp(min=5, max=30) + ), + vol.Optional(ATTR_DURATION): vol.All( + vol.Coerce(int), vol.Clamp(min=30, max=1440) + ), + } +) +SERVICE_REQUEST_HVAC_UPDATE_SCHEMA = vol.Schema({}) + +SERVICE_SET_VENTILATION_DAY_LEVEL_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): vol.All(vol.Coerce(str)), + vol.Required(ATTR_LEVEL): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6)), + } +) + +SERVICE_SET_VENTILATION_NIGHT_LEVEL_SCHEMA = SERVICE_SET_VENTILATION_DAY_LEVEL_SCHEMA + +SERVICE_SET_DATETIME_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_DATE_TIME): cv.datetime, + } +) + +SERVICES = { + SERVICE_REMOVE_QUICK_MODE: { + "schema": SERVICE_REMOVE_QUICK_MODE_SCHEMA, + }, + SERVICE_REMOVE_HOLIDAY_MODE: { + "schema": SERVICE_REMOVE_HOLIDAY_MODE_SCHEMA, + }, + SERVICE_REMOVE_QUICK_VETO: { + "schema": SERVICE_REMOVE_QUICK_VETO_SCHEMA, + "entity": True, + }, + SERVICE_SET_QUICK_MODE: { + "schema": SERVICE_SET_QUICK_MODE_SCHEMA, + }, + SERVICE_SET_HOLIDAY_MODE: { + "schema": SERVICE_SET_HOLIDAY_MODE_SCHEMA, + }, + SERVICE_SET_QUICK_VETO: {"schema": SERVICE_SET_QUICK_VETO_SCHEMA, "entity": True}, + SERVICE_REQUEST_HVAC_UPDATE: { + "schema": SERVICE_REQUEST_HVAC_UPDATE_SCHEMA, + }, + SERVICE_SET_VENTILATION_NIGHT_LEVEL: { + "schema": SERVICE_SET_VENTILATION_NIGHT_LEVEL_SCHEMA, + "entity": True, + }, + SERVICE_SET_VENTILATION_DAY_LEVEL: { + "schema": SERVICE_SET_VENTILATION_DAY_LEVEL_SCHEMA, + "entity": True, + }, + SERVICE_SET_DATETIME: {"schema": SERVICE_SET_DATETIME_SCHEMA}, +} + + +class MultimaticServiceHandler: + """Service implementation.""" + + def __init__(self, hub: MultimaticApi, hass) -> None: + """Init.""" + self.api = hub + self._hass = hass + + async def service_call(self, call): + """Handle service calls.""" + service = call.service + method = getattr(self, service) + await method(data=call.data) + + async def remove_quick_mode(self, data): + """Remove quick mode. It has impact on all components.""" + await self.api.remove_quick_mode() + + async def set_holiday_mode(self, data): + """Set holiday mode.""" + start_str = data.get(ATTR_START_DATE, None) + end_str = data.get(ATTR_END_DATE, None) + temp = data.get(ATTR_TEMPERATURE) + start = parse_date(start_str.split("T")[0]) + end = parse_date(end_str.split("T")[0]) + if end is None or start is None: + raise ValueError(f"dates are incorrect {start_str} {end_str}") + await self.api.set_holiday_mode(start, end, temp) + + async def remove_holiday_mode(self, data): + """Remove holiday mode.""" + await self.api.remove_holiday_mode() + + async def set_quick_mode(self, data): + """Set quick mode, it may impact the whole system.""" + quick_mode = data.get(ATTR_QUICK_MODE, None) + duration = data.get(ATTR_DURATION, None) + await self.api.set_quick_mode(quick_mode, duration) + + async def request_hvac_update(self, data): + """Ask multimatic API to get data from the installation.""" + await self.api.request_hvac_update() + + async def set_datetime(self, data): + """Set date time.""" + date_t: datetime = data.get(ATTR_DATE_TIME, datetime.datetime.now()) + await self.api.set_datetime(date_t) diff --git a/homeassistant/components/multimatic/services.yaml b/homeassistant/components/multimatic/services.yaml new file mode 100644 index 00000000000000..bb58cfd76b74ac --- /dev/null +++ b/homeassistant/components/multimatic/services.yaml @@ -0,0 +1,139 @@ +remove_quick_mode: + description: Remove quick mode + +remove_holiday_mode: + description: Remove holiday mode + +set_quick_mode: + description: Set a quick mode to multimatic system. + fields: + quick_mode: + description: Name of the quick mode (required) + example: QM_HOTWATER_BOOST, QM_VENTILATION_BOOST, QM_ONE_DAY_AWAY, QM_SYSTEM_OFF, QM_ONE_DAY_AT_HOME, QM_PARTY + selector: + select: + options: + - QM_HOTWATER_BOOST + - QM_VENTILATION_BOOST + - QM_ONE_DAY_AWAY + - QM_SYSTEM_OFF + - QM_ONE_DAY_AT_HOME + - QM_PARTY + duration: + description: (int) number of days the quick mode should last + example: 3 + selector: + number: + min: 0 + max: 7 + mode: box + +set_holiday_mode: + description: Set holiday mode + fields: + start_date: + description: Start date of the holiday mode YYYY-MM-DD format (required) + example: "2019-11-25" + selector: + date: + end_date: + description: End date of the holiday mode, YYYY-MM-DD format (required) + example: "2019-11-26" + selector: + date: + temperature: + description: temperature to maintin while holiday mode is active (required) + example: 15 + selector: + number: + min: 5 + max: 30 + mode: box + +set_quick_veto: + description: Set a quick veto for a climate entity + fields: + entity_id: + description: Entity id from where to set a quick veto + example: climate.bathroom + selector: + entity: + integration: multimatic + domain: climate + temperature: + description: Target temperature to be applied while quick veto is running on + example: 25 + selector: + number: + min: 5 + max: 30 + mode: box + duration: + description: Duration (in minutes) of the quick veto. Min 30min, max 1440 (24 hours). If not specified, the default (configured) duration is applied. + example: 60 + selector: + number: + min: 30 + max: 1440 + mode: box + +remove_quick_veto: + description: Remove a quick veto for a climate entity + fields: + entity_id: + description: Entity id from where to remove quick veto + example: climate.bathroom + selector: + entity: + integration: multimatic + domain: climate + +request_hvac_update: + description: Ask multimatic API to get data from your installation. + +set_ventilation_day_level: + description: Set day level ventilation + fields: + entity_id: + description: Entity id of the fan + example: fan.bathroom + selector: + entity: + integration: multimatic + domain: fan + level: + description: Level to set (required) + example: 1 + selector: + number: + min: 1 + max: 7 + mode: box + +set_ventilation_night_level: + description: Set night level ventilation + fields: + entity_id: + description: Entity id of the fan + example: fan.bathroom + selector: + entity: + integration: multimatic + domain: fan + level: + description: Level to set (required) + example: 2 + selector: + number: + min: 1 + max: 7 + mode: box + +set_datetime: + description: Set multimatic system datetime + fields: + datetime: + description: datetime to set + example: 2022-11-06T11:11:38 + selector: + datetime: diff --git a/homeassistant/components/multimatic/strings.json b/homeassistant/components/multimatic/strings.json new file mode 100644 index 00000000000000..85261ef0bbdec1 --- /dev/null +++ b/homeassistant/components/multimatic/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "Application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} diff --git a/homeassistant/components/multimatic/translations/bg.json b/homeassistant/components/multimatic/translations/bg.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/bg.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/ca.json b/homeassistant/components/multimatic/translations/ca.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/ca.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/cs.json b/homeassistant/components/multimatic/translations/cs.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/cs.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/da.json b/homeassistant/components/multimatic/translations/da.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/da.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/de.json b/homeassistant/components/multimatic/translations/de.json new file mode 100644 index 00000000000000..e15ad9505962e8 --- /dev/null +++ b/homeassistant/components/multimatic/translations/de.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername", + "application": "Applikation", + "serial_number": "Seriennummer" + }, + "title": "Verbindungsinformationen (wie in der mutliMATIC / sensoAPP)" + } + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, bitte erneut versuchen", + "invalid_auth": "Benutzer oder Passwort falsch", + "unknown": "Unbekannter Fehler" + }, + "abort": { + "already_configured": "Nur eine Verbindung erlaubt" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minuten zwischen Scans" + } + } + } + } +} diff --git a/homeassistant/components/multimatic/translations/en.json b/homeassistant/components/multimatic/translations/en.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/es.json b/homeassistant/components/multimatic/translations/es.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/es.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/fr.json b/homeassistant/components/multimatic/translations/fr.json new file mode 100644 index 00000000000000..ae92124dc5f7a4 --- /dev/null +++ b/homeassistant/components/multimatic/translations/fr.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Utilisateur", + "application": "Application", + "serial_number": "Numéro de série" + }, + "title": "Identifiant (les même que pour vous identifier à l'application) " + } + }, + "error": { + "cannot_connect": "Impossible de se connecter", + "invalid_auth": "Identifiants incorrects", + "unknown": "Erreur inattendue" + }, + "abort": { + "already_configured": "Une seule instance de l'intégration authorisée" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes entre chaque rafraîchissement" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/it.json b/homeassistant/components/multimatic/translations/it.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/it.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/ko.json b/homeassistant/components/multimatic/translations/ko.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/ko.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/lb.json b/homeassistant/components/multimatic/translations/lb.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/lb.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/nl.json b/homeassistant/components/multimatic/translations/nl.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/nl.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/nn.json b/homeassistant/components/multimatic/translations/nn.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/nn.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/no.json b/homeassistant/components/multimatic/translations/no.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/no.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/pl.json b/homeassistant/components/multimatic/translations/pl.json new file mode 100644 index 00000000000000..cb08e5b437d182 --- /dev/null +++ b/homeassistant/components/multimatic/translations/pl.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Hasło", + "username": "Nazwa użytkownika", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Informacje o połączeniu (takie same jak w aplikacji multiMATIC)" + } + }, + "error": { + "cannot_connect": "Nie udało się połączyć, spróbuj ponownie", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Niespodziewany błąd" + }, + "abort": { + "already_configured": "Dozwolona jest tylko jedna konfiguracja" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/pt-BR.json b/homeassistant/components/multimatic/translations/pt-BR.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/pt-BR.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/pt.json b/homeassistant/components/multimatic/translations/pt.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/pt.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/ru.json b/homeassistant/components/multimatic/translations/ru.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/ru.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/sl.json b/homeassistant/components/multimatic/translations/sl.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/sl.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/translations/zh-Hant.json b/homeassistant/components/multimatic/translations/zh-Hant.json new file mode 100644 index 00000000000000..b7b25254b798a7 --- /dev/null +++ b/homeassistant/components/multimatic/translations/zh-Hant.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username", + "application": "Application", + "serial_number": "Serial number" + }, + "title": "Connection information (same as multiMATIC application)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Only one configuration is allowed" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/multimatic/utils.py b/homeassistant/components/multimatic/utils.py new file mode 100644 index 00000000000000..c2e24274c19b6a --- /dev/null +++ b/homeassistant/components/multimatic/utils.py @@ -0,0 +1,51 @@ +"""Utility.""" +from datetime import datetime + +from pymultimatic.model import HolidayMode, QuickMode, QuickModes + +from .const import COORDINATORS, DOMAIN as MULTIMATIC + +_DATE_FORMAT = "%Y-%m-%d" + + +def get_coordinator(hass, key: str, entry_id: str): + """Get coordinator from hass data.""" + return hass.data[MULTIMATIC][entry_id][COORDINATORS][key] + + +def holiday_mode_to_json(holiday_mode): + """Convert holiday to json.""" + if holiday_mode and holiday_mode.is_applied: + return { + "active": True, + "start_date": holiday_mode.start_date.strftime(_DATE_FORMAT), + "end_date": holiday_mode.end_date.strftime(_DATE_FORMAT), + "target": holiday_mode.target, + } + return None + + +def holiday_mode_from_json(str_json) -> HolidayMode: + """Convert json to holiday mode.""" + if str_json: + return HolidayMode( + str_json["active"], + datetime.strptime(str_json["start_date"], _DATE_FORMAT).date(), + datetime.strptime(str_json["end_date"], _DATE_FORMAT).date(), + str_json["target"], + ) + return HolidayMode(False) + + +def quick_mode_to_json(quick_mode): + """Convert quick mode to json.""" + if quick_mode: + return {"name": quick_mode.name, "duration": quick_mode.duration} + return None + + +def quick_mode_from_json(str_json) -> QuickMode: + """Convert json to quick mode.""" + if str_json: + return QuickModes.get(str_json["name"], str_json["duration"]) + return None diff --git a/homeassistant/components/multimatic/water_heater.py b/homeassistant/components/multimatic/water_heater.py new file mode 100644 index 00000000000000..f4ed6445309f96 --- /dev/null +++ b/homeassistant/components/multimatic/water_heater.py @@ -0,0 +1,171 @@ +"""Interfaces with multimatic water heater.""" +import logging + +from pymultimatic.model import HotWater, OperatingModes, QuickModes + +from homeassistant.components.water_heater import ( + DOMAIN, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from .const import DHW +from .coordinator import MultimaticCoordinator +from .entities import MultimaticEntity +from .utils import get_coordinator + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FLAGS = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE +) +ATTR_CURRENT_TEMPERATURE = "current_temperature" +ATTR_TIME_PROGRAM = "time_program" + +AWAY_MODES = [ + OperatingModes.OFF, + QuickModes.HOLIDAY, + QuickModes.ONE_DAY_AWAY, + QuickModes.SYSTEM_OFF, +] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up water_heater platform.""" + entities = [] + coordinator = get_coordinator(hass, DHW, entry.unique_id) + + if coordinator.data and coordinator.data.hotwater: + entities.append(MultimaticWaterHeater(coordinator)) + + async_add_entities(entities) + return True + + +class MultimaticWaterHeater(MultimaticEntity, WaterHeaterEntity): + """Represent the multimatic water heater.""" + + def __init__(self, coordinator: MultimaticCoordinator) -> None: + """Initialize entity.""" + super().__init__(coordinator, DOMAIN, coordinator.data.hotwater.id) + self._operations = {mode.name: mode for mode in HotWater.MODES} + self._name = coordinator.data.hotwater.name + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def component(self): + """Return multimatic component.""" + return self.coordinator.data.hotwater + + @property + def active_mode(self): + """Return multimatic component's active mode.""" + return self.coordinator.api.get_active_mode(self.component) + + @property + def supported_features(self): + """Return the list of supported features. + + !! It could be misleading here, since when heater is not heating, + target temperature if fixed (35 °C) - The API doesn't allow to change + this setting. It means if the user wants to change the target + temperature, it will always be the target temperature when the + heater is on function. See example below: + + 1. Target temperature when heater is off is 35 (this is a fixed + setting) + 2. Target temperature when heater is on is for instance 50 (this is a + configurable setting) + 3. While heater is off, user changes target_temperature to 45. It will + actually change the target temperature from 50 to 45 + 4. While heater is off, user will still see 35 in UI + (even if he changes to 45 before) + 5. When heater will go on, user will see the target temperature he set + at point 3 -> 45. + + Maybe I can remove the SUPPORT_TARGET_TEMPERATURE flag if the heater + is off, but it means the user will be able to change the target + temperature only when the heater is ON (which seems odd to me) + """ + if self.active_mode != QuickModes.HOLIDAY: + return SUPPORTED_FLAGS + return 0 + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.component is not None + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.active_mode.target + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.component.temperature + + @property + def min_temp(self): + """Return the minimum temperature.""" + return HotWater.MIN_TARGET_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return HotWater.MAX_TARGET_TEMP + + @property + def current_operation(self): + """Return current operation ie. eco, electric, performance, ...""" + return self.active_mode.current.name + + @property + def operation_list(self): + """Return current operation ie. eco, electric, performance, ...""" + if self.active_mode.current != QuickModes.HOLIDAY: + return list(self._operations.keys()) + return [] + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self.active_mode.current in AWAY_MODES + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = float(kwargs.get(ATTR_TEMPERATURE)) + await self.coordinator.api.set_hot_water_target_temperature(self, target_temp) + + async def async_set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + if operation_mode in self._operations.keys(): + mode = self._operations[operation_mode] + await self.coordinator.api.set_hot_water_operating_mode(self, mode) + else: + _LOGGER.debug("Operation mode %s is unknown", operation_mode) + + async def async_turn_away_mode_on(self): + """Turn away mode on.""" + await self.coordinator.api.set_hot_water_operating_mode( + self, OperatingModes.OFF + ) + + async def async_turn_away_mode_off(self): + """Turn away mode off.""" + await self.coordinator.api.set_hot_water_operating_mode( + self, OperatingModes.AUTO + ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 980f7f1897eaf1..256aab338a1bf9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -262,6 +262,7 @@ "motioneye", "mqtt", "mullvad", + "multimatic", "mutesync", "myq", "mysensors", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 40d6edc0d495d8..472cd17100c1eb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3398,6 +3398,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "multimatic": { + "name": "Multimatic", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "mutesync": { "name": "mutesync", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 47ad850cf2232a..06e8be4cb25092 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1760,6 +1760,9 @@ pymonoprice==0.4 # homeassistant.components.msteams pymsteams==0.1.12 +# homeassistant.components.multimatic +pymultimatic==0.6.11 + # homeassistant.components.myq pymyq==3.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59c776a0efaf06..a19ff6f4106ea7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1249,6 +1249,9 @@ pymodbus==2.5.3 # homeassistant.components.monoprice pymonoprice==0.4 +# homeassistant.components.multimatic +pymultimatic==0.6.11 + # homeassistant.components.myq pymyq==3.1.4 diff --git a/tests/components/multimatic/__init__.py b/tests/components/multimatic/__init__.py new file mode 100644 index 00000000000000..be2fd40ebf493e --- /dev/null +++ b/tests/components/multimatic/__init__.py @@ -0,0 +1,349 @@ +"""The tests for multimatic integration.""" +from __future__ import annotations + +import datetime +from typing import Any +from unittest.mock import AsyncMock, patch + +from pymultimatic.model import ( + ActiveFunction, + BoilerStatus, + Circulation, + Device, + Dhw, + EmfReport, + Error, + FacilityDetail, + HolidayMode, + HotWater, + HvacStatus, + OperatingModes, + Report, + Room, + SettingModes, + TimePeriodSetting, + TimeProgram, + TimeProgramDay, + Ventilation, + Zone, + ZoneHeating, +) +from pymultimatic.systemmanager import SystemManager + +from homeassistant import config_entries +from homeassistant.components.multimatic import COORDINATORS, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.util import utcnow + +from tests.common import async_fire_time_changed + +VALID_MINIMAL_CONFIG = {CONF_USERNAME: "test", CONF_PASSWORD: "test"} + + +class SystemManagerMock(SystemManager): + """Mock implementation of SystemManager.""" + + instance = None + _methods = [f for f in dir(SystemManager) if not f.startswith("_")] + data: dict[str, Any] = {} + + @classmethod + def reset_mock(cls): + """Reset mock, clearing instance and system.""" + cls.instance = None + cls.data = {} + + def __init__( + self, + user: str, + password: str, + session: any, + smartphone_id: str = "test", + serial: any = None, + ): + """Mock the constructor.""" + self._init() + self.__class__.instance = self + + def _init(self): + def _handle_mock(name): + data = self.data.get(name) + if isinstance(data, BaseException): + raise data + return data + + for method in self.__class__._methods: + setattr( + self, + method, + AsyncMock( + side_effect=lambda x=method, *args, **kwargs: _handle_mock(x) + ), + ) + + @classmethod + def init_defaults(cls): + """Init mock data with some default values.""" + cls.update_data( + { + "get_zones": zones(True), + "get_rooms": rooms(), + "get_dhw": dhw(), + "get_live_reports": reports(), + "get_outdoor_temperature": 18, + "get_ventilation": ventilation(), + "get_quick_mode": None, + "get_holiday_mode": HolidayMode(False), + "get_hvac_status": hvac_status(), + "get_facility_detail": facility_detail(), + "get_gateway": "VR920", + "get_live_report": report(), + "DomesticHotWaterTankTemperature": report_dhw(), + "get_emf_devices": emf_reports(), + } + ) + + @classmethod + def update_data(cls, data): + """Modify data.""" + cls.data.update(data) + + +def zones(with_rb=True): + """Get zones.""" + zones = [] + heating = ZoneHeating( + time_program=time_program(SettingModes.NIGHT, None), + operating_mode=OperatingModes.AUTO, + target_low=22, + target_high=30, + ) + + zones.append( + Zone( + id="zone_1", + name="Zone 1", + temperature=25, + active_function=ActiveFunction.HEATING, + rbr=False, + heating=heating, + ) + ) + + if with_rb: + zones.append( + Zone( + id="zone_2", + name="Zone rbr", + temperature=25, + active_function=ActiveFunction.HEATING, + rbr=True, + heating=heating, + ) + ) + return zones + + +def rooms(): + """Get rooms.""" + room_device = Device("Device 1", "123456789", "VALVE", False, False) + return [ + Room( + id="1", + name="Room 1", + time_program=time_program(), + temperature=22, + target_high=24, + operating_mode=OperatingModes.AUTO, + child_lock=False, + window_open=False, + devices=[room_device], + ) + ] + + +def dhw(): + """Get dhw.""" + hot_water = HotWater( + id="dhw", + name="Hot water", + time_program=time_program(temp=None), + temperature=None, + target_high=40, + operating_mode=OperatingModes.AUTO, + ) + + circulation = Circulation( + id="dhw", + name="Circulation", + time_program=time_program(temp=None), + operating_mode=OperatingModes.AUTO, + ) + return Dhw(hotwater=hot_water, circulation=circulation) + + +def report(): + """Get report.""" + return Report( + device_name="VRC700 MultiMatic", + device_id="Control_SYS_MultiMatic", + unit="bar", + value=1.9, + name="Water pressure", + id="WaterPressureSensor", + ) + + +def report_dhw(): + """Get report for dhw.""" + return Report( + device_name="Control_DHW", + device_id="DomesticHotWaterTankTemperature", + unit="°C", + value=45, + name="DomesticHotWaterTankTemperature", + id="DomesticHotWaterTankTemperature", + ) + + +def reports(): + """Get reports.""" + return [report()] + + +def ventilation(): + """Return ventilation.""" + return Ventilation( + time_program=time_program(SettingModes.ON, 6), + operating_mode=OperatingModes.AUTO, + target_high=6, + target_low=2, + id="ventilation", + name="Ventilation", + ) + + +def active_holiday_mode(): + """Return a active holiday mode.""" + start = datetime.date.today() - datetime.timedelta(days=1) + end = datetime.date.today() + datetime.timedelta(days=1) + return HolidayMode(True, start, end, 15) + + +def time_program(heating_mode=SettingModes.OFF, temp=20): + """Create a default time program.""" + tp_day_setting = TimePeriodSetting("00:00", temp, heating_mode) + tp_day = TimeProgramDay([tp_day_setting]) + tp_days = { + "monday": tp_day, + "tuesday": tp_day, + "wednesday": tp_day, + "thursday": tp_day, + "friday": tp_day, + "saturday": tp_day, + "sunday": tp_day, + } + return TimeProgram(tp_days) + + +def facility_detail(): + """Get facility detail.""" + return FacilityDetail( + name="Home", + serial_number="12345", + firmware_version="1.2.3", + ethernet_mac="01:23:45:67:89:AB", + wifi_mac="23:45:67:89:0A:BC", + ) + + +def hvac_status(with_error=False, with_status=True): + """Get hvac status.""" + boiler_status = None + if with_status: + boiler_status = BoilerStatus( + device_name="boiler", + title="Status", + status_code="1", + description="This is the status", + timestamp=datetime.datetime.now(), + hint="Do nothing", + ) + + errors = None + if with_error: + errors = [ + Error( + device_name="Device", + title="Status", + status_code="99", + description="This is the error", + timestamp=datetime.datetime.now(), + ) + ] + + return HvacStatus( + boiler_status=boiler_status, + errors=errors, + online="ONLINE", + update="UPDATE_NOT_PENDING", + ) + + +def emf_reports(): + """Get emf reports.""" + return [ + EmfReport( + "flexoTHERM_PR_EBUS", + "VWF 117/4", + "HEAT_PUMP", + "COOLING", + "CONSUMED_ELECTRICAL_POWER", + 1000, + datetime.date(2021, 1, 1), + datetime.date(2021, 1, 10), + ) + ] + + +async def goto_future(hass): + """Move to future.""" + future = utcnow() + datetime.timedelta(minutes=5) + with patch("homeassistant.util.utcnow", return_value=future): + async_fire_time_changed(hass, future) + entry_id = hass.config_entries.async_entries(DOMAIN)[0].unique_id + coordinators = hass.data[DOMAIN][entry_id][COORDINATORS] + for coord in coordinators.values(): + await coord.async_request_refresh() + await hass.async_block_till_done() + + +async def setup_multimatic(hass, config=None, with_defaults=True, data=None): + """Set up multimatic component.""" + if not config: + config = VALID_MINIMAL_CONFIG + if with_defaults: + SystemManagerMock.init_defaults() + if data: + SystemManagerMock.update_data(data) + + with patch( + "homeassistant.components.multimatic.config_flow.validate_authentication", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config + ) + await hass.async_block_till_done() + return result + + +async def call_service(hass, domain, service, data): + """Call hass service.""" + await hass.services.async_call(domain, service, data) + await hass.async_block_till_done() + + +def assert_entities_count(hass, count): + """Count entities owned by the component.""" + assert len(hass.states.async_entity_ids()) == count diff --git a/tests/components/multimatic/conftest.py b/tests/components/multimatic/conftest.py new file mode 100644 index 00000000000000..8009f8543f233c --- /dev/null +++ b/tests/components/multimatic/conftest.py @@ -0,0 +1,15 @@ +"""Fixtures for multimatic tests.""" + +from unittest import mock + +import pytest + +from tests.components.multimatic import SystemManagerMock + + +@pytest.fixture(name="mock_manager") +def fixture_mock_manager(): + """Mock the multimatic system manager.""" + with mock.patch("pymultimatic.systemmanager.SystemManager", new=SystemManagerMock): + yield + SystemManagerMock.reset_mock() diff --git a/tests/components/multimatic/test_binary_sensor.py b/tests/components/multimatic/test_binary_sensor.py new file mode 100644 index 00000000000000..079f9c70efed8c --- /dev/null +++ b/tests/components/multimatic/test_binary_sensor.py @@ -0,0 +1,111 @@ +"""Tests for the multimatic sensor.""" +import datetime + +from pymultimatic.model import ( + Device, + Error, + HolidayMode, + OperatingModes, + QuickModes, + SettingModes, +) +import pytest + +import homeassistant.components.multimatic as multimatic + +from tests.components.multimatic import ( + SystemManagerMock, + active_holiday_mode, + assert_entities_count, + goto_future, + setup_multimatic, + time_program, +) + + +@pytest.fixture(autouse=True) +def fixture_only_binary_sensor(mock_manager): + """Mock multimatic to only handle binary_sensor.""" + orig_platforms = multimatic.PLATFORMS + multimatic.PLATFORMS = ["binary_sensor"] + yield + multimatic.PLATFORMS = orig_platforms + + +async def test_valid_config(hass): + """Test setup with valid config.""" + assert await setup_multimatic(hass) + assert_entities_count(hass, 11) + + +async def test_state_update(hass): + """Test all sensors are updated accordingly to data.""" + assert await setup_multimatic(hass) + assert_entities_count(hass, 11) + + assert hass.states.is_state("binary_sensor.dhw_circulation", "off") + assert hass.states.is_state("binary_sensor.room_1_window", "off") + assert hass.states.is_state("binary_sensor.123456789_lock", "on") + assert hass.states.is_state("binary_sensor.123456789_battery", "off") + assert hass.states.is_state("binary_sensor.boiler", "off") + assert hass.states.is_state("binary_sensor.123456789_connectivity", "on") + assert hass.states.is_state("binary_sensor.multimatic_system_update", "off") + assert hass.states.is_state("binary_sensor.multimatic_system_online", "on") + assert hass.states.is_state("binary_sensor.multimatic_holiday", "off") + assert hass.states.is_state("binary_sensor.multimatic_errors", "off") + state = hass.states.get("binary_sensor.multimatic_holiday") + assert state.attributes.get("start_date") is None + assert state.attributes.get("end_date") is None + assert state.attributes.get("temperature") is None + assert hass.states.is_state("binary_sensor.multimatic_quick_mode", "off") + + dhw = SystemManagerMock.data["get_dhw"] + dhw.circulation.time_program = time_program(SettingModes.ON, None) + dhw.circulation.operating_mode = OperatingModes.AUTO + + hvac_status = SystemManagerMock.data["get_hvac_status"] + hvac_status.boiler_status.status_code = "F11" + hvac_status.online = "OFFLINE" + hvac_status.update = "UPDATE_PENDING" + hvac_status.errors = [ + Error("device", "title", "status_code", "descr", datetime.datetime.now()) + ] + + rooms = SystemManagerMock.data["get_rooms"] + rooms[0].devices = [Device("Device 1", "123456789", "VALVE", True, True)] + rooms[0].time_program = time_program(None, 20) + rooms[0].temperature = 22 + rooms[0].target_high = 24 + rooms[0].operating_mode = OperatingModes.AUTO + rooms[0].child_lock = True + rooms[0].window_open = True + + new_holiday_mode = active_holiday_mode() + SystemManagerMock.data["get_holiday_mode"] = new_holiday_mode + SystemManagerMock.data["get_quick_mode"] = QuickModes.HOTWATER_BOOST + + await goto_future(hass) + + assert_entities_count(hass, 11) + assert hass.states.is_state("binary_sensor.room_1_window", "on") + assert hass.states.is_state("binary_sensor.123456789_lock", "off") + assert hass.states.is_state("binary_sensor.123456789_battery", "on") + assert hass.states.is_state("binary_sensor.boiler", "on") + assert hass.states.is_state("binary_sensor.123456789_connectivity", "off") + assert hass.states.is_state("binary_sensor.multimatic_system_update", "on") + assert hass.states.is_state("binary_sensor.multimatic_system_online", "off") + assert hass.states.is_state("binary_sensor.multimatic_errors", "on") + assert hass.states.is_state("binary_sensor.multimatic_holiday", "on") + state = hass.states.get("binary_sensor.multimatic_holiday") + assert state.attributes["start_date"] == new_holiday_mode.start_date.isoformat() + assert state.attributes["end_date"] == new_holiday_mode.end_date.isoformat() + assert state.attributes["temperature"] == new_holiday_mode.target + assert hass.states.is_state("binary_sensor.multimatic_quick_mode", "on") + state = hass.states.get("binary_sensor.multimatic_quick_mode") + assert state.attributes["quick_mode"] == QuickModes.HOTWATER_BOOST.name + assert hass.states.is_state("binary_sensor.dhw_circulation", "off") + + SystemManagerMock.data["get_holiday_mode"] = HolidayMode(False) + + await goto_future(hass) + assert hass.states.is_state("binary_sensor.dhw_circulation", "on") diff --git a/tests/components/multimatic/test_climate_room.py b/tests/components/multimatic/test_climate_room.py new file mode 100644 index 00000000000000..cae97850e1cb4f --- /dev/null +++ b/tests/components/multimatic/test_climate_room.py @@ -0,0 +1,320 @@ +"""Tests for the multimatic sensor.""" + +from pymultimatic.model import ( + ActiveFunction, + OperatingModes, + QuickMode, + QuickModes, + Room, +) +import pytest + +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_COMFORT, + PRESET_NONE, +) +import homeassistant.components.multimatic as multimatic +from homeassistant.components.multimatic.const import ( + PRESET_HOLIDAY, + PRESET_MANUAL, + PRESET_QUICK_VETO, + PRESET_SYSTEM_OFF, +) + +from tests.components.multimatic import ( + SystemManagerMock, + active_holiday_mode, + assert_entities_count, + call_service, + goto_future, + setup_multimatic, + time_program, +) + + +def _assert_room_state(hass, mode, hvac, current_temp, temp, preset, action): + """Assert room climate state.""" + state = hass.states.get("climate.room_1") + + assert hass.states.is_state("climate.room_1", hvac) + assert state.attributes["current_temperature"] == current_temp + assert state.attributes["max_temp"] == Room.MAX_TARGET_TEMP + assert state.attributes["min_temp"] == Room.MIN_TARGET_TEMP + assert state.attributes["temperature"] == temp + # assert state.attributes[ATTR_MULTIMATIC_MODE] == mode.name + assert state.attributes["hvac_action"] == action + assert state.attributes["preset_mode"] == preset + + +@pytest.fixture(autouse=True) +def fixture_only_climate(mock_manager): + """Mock multimatic to only handle sensor.""" + orig_platforms = multimatic.PLATFORMS + multimatic.PLATFORMS = ["climate"] + yield + multimatic.PLATFORMS = orig_platforms + + +async def test_valid_config(hass): + """Test setup with valid config.""" + assert await setup_multimatic(hass) + assert_entities_count(hass, 2) + _assert_room_state( + hass, + OperatingModes.AUTO, + HVAC_MODE_AUTO, + 22, + 20, + PRESET_COMFORT, + CURRENT_HVAC_IDLE, + ) + + +async def test_no_data(hass): + """Test setup with empty system.""" + assert await setup_multimatic(hass, with_defaults=False, data={}) + assert_entities_count(hass, 0) + + +async def test_state_update_room(hass): + """Test room climate is updated accordingly to data.""" + assert await setup_multimatic(hass) + _assert_room_state( + hass, + OperatingModes.AUTO, + HVAC_MODE_AUTO, + 22, + 20, + PRESET_COMFORT, + CURRENT_HVAC_IDLE, + ) + + rooms = SystemManagerMock.data["get_rooms"] + zones = SystemManagerMock.data["get_zones"] + room = rooms[0] + room.temperature = 25 + room.target_high = 30 + room.time_program = time_program(None, 30) + rbr_zone = [zone for zone in zones if zone.rbr][0] + rbr_zone.active_function = ActiveFunction.HEATING + await goto_future(hass) + + _assert_room_state( + hass, + OperatingModes.AUTO, + HVAC_MODE_AUTO, + 25, + 30, + PRESET_COMFORT, + CURRENT_HVAC_HEAT, + ) + + +async def _test_mode_hvac(hass, mode, function, hvac_mode, target_temp, preset, action): + SystemManagerMock.init_defaults() + + if isinstance(mode, QuickMode): + SystemManagerMock.data["get_quick_mode"] = mode + else: + SystemManagerMock.data["get_rooms"][0].operating_mode = mode + + rbr_zone = [zone for zone in SystemManagerMock.data["get_zones"] if zone.rbr][0] + rbr_zone.active_function = function + + assert await setup_multimatic(hass, with_defaults=False) + room = SystemManagerMock.data["get_rooms"][0] + _assert_room_state( + hass, mode, hvac_mode, room.temperature, target_temp, preset, action + ) + + +async def test_auto_mode_hvac_auto(hass): + """Test with auto mode.""" + SystemManagerMock.init_defaults() + room = SystemManagerMock.data["get_rooms"][0] + await _test_mode_hvac( + hass, + OperatingModes.AUTO, + ActiveFunction.HEATING, + HVAC_MODE_AUTO, + room.active_mode.target, + PRESET_COMFORT, + CURRENT_HVAC_IDLE, + ) + + +async def test_off_mode_hvac_off(hass): + """Test with off mode.""" + await _test_mode_hvac( + hass, + OperatingModes.OFF, + "IDLE", + HVAC_MODE_OFF, + Room.MIN_TARGET_TEMP, + PRESET_NONE, + CURRENT_HVAC_IDLE, + ) + + +async def test_quickmode_system_off_mode_hvac_off(hass): + """Test with quick mode off.""" + await _test_mode_hvac( + hass, + QuickModes.SYSTEM_OFF, + "IDLE", + HVAC_MODE_OFF, + Room.MIN_TARGET_TEMP, + PRESET_SYSTEM_OFF, + CURRENT_HVAC_IDLE, + ) + + +async def test_holiday_mode(hass): + """Test with holiday mode.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_holiday_mode"] = active_holiday_mode() + rbr_zone = [zone for zone in SystemManagerMock.data["get_zones"] if zone.rbr][0] + rbr_zone.active_function = "IDLE" + + assert await setup_multimatic(hass, with_defaults=False) + + _assert_room_state( + hass, + QuickModes.HOLIDAY, + HVAC_MODE_OFF, + SystemManagerMock.data["get_rooms"][0].temperature, + 15, + PRESET_HOLIDAY, + CURRENT_HVAC_IDLE, + ) + + +async def test_set_target_temp_cool(hass): + """Test hvac is cool with lower target temp.""" + SystemManagerMock.init_defaults() + room = SystemManagerMock.data["get_rooms"][0] + rbr_zone = [zone for zone in SystemManagerMock.data["get_zones"] if zone.rbr][0] + rbr_zone.active_function = "IDLE" + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "climate", + "set_temperature", + {"entity_id": "climate.room_1", "temperature": 14}, + ) + + _assert_room_state( + hass, + OperatingModes.QUICK_VETO, + "unknown", + room.temperature, + 14, + PRESET_QUICK_VETO, + CURRENT_HVAC_IDLE, + ) + SystemManagerMock.instance.set_room_quick_veto.assert_called_once() + + +async def test_set_target_temp_heat(hass): + """Test hvac is heat with higher target temp.""" + SystemManagerMock.init_defaults() + room = SystemManagerMock.data["get_rooms"][0] + rbr_zone = [zone for zone in SystemManagerMock.data["get_zones"] if zone.rbr][0] + rbr_zone.active_function = ActiveFunction.HEATING + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "climate", + "set_temperature", + {"entity_id": "climate.room_1", "temperature": 30}, + ) + + _assert_room_state( + hass, + OperatingModes.QUICK_VETO, + HVAC_MODE_HEAT, + room.temperature, + 30, + PRESET_QUICK_VETO, + CURRENT_HVAC_HEAT, + ) + SystemManagerMock.instance.set_room_quick_veto.assert_called_once() + + +async def test_room_manual(hass): + """Test hvac is heating with higher target temp.""" + SystemManagerMock.init_defaults() + room = SystemManagerMock.data["get_rooms"][0] + room.operating_mode = OperatingModes.MANUAL + room.temperature = 15 + room.target_high = 25 + rbr_zone = [zone for zone in SystemManagerMock.data["get_zones"] if zone.rbr][0] + rbr_zone.active_function = ActiveFunction.HEATING + + assert await setup_multimatic(hass, with_defaults=False) + _assert_room_state( + hass, + OperatingModes.MANUAL, + HVAC_MODE_HEAT, + 15, + 25, + PRESET_MANUAL, + CURRENT_HVAC_HEAT, + ) + + +async def test_room_manual_cool(hass): + """Test hvac is not heating at higher target temp.""" + SystemManagerMock.init_defaults() + room = SystemManagerMock.data["get_rooms"][0] + room.operating_mode = OperatingModes.MANUAL + room.temperature = 20 + room.target_high = 18 + rbr_zone = [zone for zone in SystemManagerMock.data["get_zones"] if zone.rbr][0] + rbr_zone.active_function = "IDLE" + + assert await setup_multimatic(hass, with_defaults=False) + _assert_room_state( + hass, OperatingModes.MANUAL, "unknown", 20, 18, PRESET_MANUAL, CURRENT_HVAC_IDLE + ) + + +async def test_room_without_zone(hass): + """Test room is still ok withtout related zone.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_zones"] = [] + + assert await setup_multimatic(hass, with_defaults=False) + _assert_room_state( + hass, + OperatingModes.AUTO, + HVAC_MODE_AUTO, + 22, + 20, + PRESET_COMFORT, + CURRENT_HVAC_IDLE, + ) + + await call_service( + hass, + "climate", + "set_temperature", + {"entity_id": "climate.room_1", "temperature": 30}, + ) + + _assert_room_state( + hass, + OperatingModes.QUICK_VETO, + "unknown", + 22, + 30, + PRESET_QUICK_VETO, + CURRENT_HVAC_IDLE, + ) diff --git a/tests/components/multimatic/test_climate_zone.py b/tests/components/multimatic/test_climate_zone.py new file mode 100644 index 00000000000000..4e2cf47633cf10 --- /dev/null +++ b/tests/components/multimatic/test_climate_zone.py @@ -0,0 +1,451 @@ +"""Tests for the multimatic zone climate.""" + +import logging + +from pymultimatic.model import ( + ActiveFunction, + OperatingModes, + QuickMode, + QuickModes, + SettingModes, + Zone, + ZoneCooling, +) +import pytest + +from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_COMFORT, + PRESET_HOME, + PRESET_NONE, + PRESET_SLEEP, +) +import homeassistant.components.multimatic as multimatic +from homeassistant.components.multimatic.const import ( + PRESET_COOLING_FOR_X_DAYS, + PRESET_DAY, + PRESET_HOLIDAY, + PRESET_PARTY, + PRESET_QUICK_VETO, + PRESET_SYSTEM_OFF, +) + +from tests.components.multimatic import ( + SystemManagerMock, + active_holiday_mode, + assert_entities_count, + call_service, + goto_future, + setup_multimatic, + time_program, +) + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(autouse=True) +def fixture_only_climate(mock_manager): + """Mock multimatic to only handle sensor.""" + orig_platforms = multimatic.PLATFORMS + multimatic.PLATFORMS = ["climate"] + yield + multimatic.PLATFORMS = orig_platforms + + +def _assert_zone_state(hass, mode, hvac, current_temp, target_temp, preset, action): + """Assert zone climate state.""" + state = hass.states.get("climate.zone_1") + + assert hass.states.is_state("climate.zone_1", hvac) + assert state.attributes["current_temperature"] == current_temp + assert state.attributes["max_temp"] == Zone.MAX_TARGET_TEMP + assert state.attributes["min_temp"] == Zone.MIN_TARGET_HEATING_TEMP + assert state.attributes["temperature"] == target_temp + assert state.attributes["hvac_action"] == action + assert state.attributes["preset_mode"] == preset + + expected_modes = {HVAC_MODE_OFF, HVAC_MODE_AUTO, HVAC_MODE_FAN_ONLY} + + zone = SystemManagerMock.data.get("get_zones")[0] + if zone.cooling: + expected_modes.update({HVAC_MODE_COOL}) + + assert set(state.attributes["hvac_modes"]) == expected_modes + + +async def test_valid_config(hass): + """Test setup with valid config.""" + assert await setup_multimatic(hass) + # one room, one zone + assert_entities_count(hass, 2) + zone = SystemManagerMock.data.get("get_zones")[0] + _assert_zone_state( + hass, + OperatingModes.AUTO, + HVAC_MODE_AUTO, + zone.temperature, + zone.active_mode.target, + PRESET_COMFORT, + CURRENT_HVAC_HEAT, + ) + + +async def test_empty_system(hass): + """Test setup with empty system.""" + assert await setup_multimatic(hass, with_defaults=False, data={}) + assert_entities_count(hass, 0) + + +async def _test_mode_hvac(hass, mode, function, hvac_mode, target_temp, preset, action): + if isinstance(mode, QuickMode): + SystemManagerMock.data["get_quick_mode"] = mode + else: + SystemManagerMock.data["get_zones"][0].heating.operating_mode = mode + + SystemManagerMock.data["get_zones"][0].active_function = function + + assert await setup_multimatic(hass, with_defaults=False) + zone = SystemManagerMock.data["get_zones"][0] + _assert_zone_state( + hass, mode, hvac_mode, zone.temperature, target_temp, preset, action + ) + + +async def _test_set_hvac( + hass, mode, function, hvac_mode, current_temp, target_temp, preset, action +): + SystemManagerMock.data["get_zones"][0].active_function = function + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "climate", + "set_hvac_mode", + {"entity_id": "climate.zone_1", "hvac_mode": hvac_mode}, + ) + + _assert_zone_state(hass, mode, hvac_mode, current_temp, target_temp, preset, action) + + +async def test_day_mode_hvac_heat(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data.get("get_zones")[0] + await _test_mode_hvac( + hass, + OperatingModes.DAY, + ActiveFunction.HEATING, + HVAC_MODE_HEAT, + zone.heating.target_high, + PRESET_DAY, + CURRENT_HVAC_HEAT, + ) + + +async def test_day_mode_hvac_idle(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data.get("get_zones")[0] + zone.temperature = 20 + zone.heating.target_high = 15 + zone.heating.target_low = 10 + await _test_mode_hvac( + hass, + OperatingModes.DAY, + ActiveFunction.STANDBY, + HVAC_MODE_OFF, + zone.heating.target_high, + PRESET_DAY, + CURRENT_HVAC_IDLE, + ) + + +async def test_night_mode_hvac_idle(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data.get("get_zones")[0] + await _test_mode_hvac( + hass, + OperatingModes.NIGHT, + ActiveFunction.STANDBY, + HVAC_MODE_OFF, + zone.heating.target_low, + PRESET_SLEEP, + CURRENT_HVAC_IDLE, + ) + + +async def test_auto_mode_hvac_auto(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data.get("get_zones")[0] + await _test_mode_hvac( + hass, + OperatingModes.AUTO, + ActiveFunction.STANDBY, + HVAC_MODE_AUTO, + zone.active_mode.target, + PRESET_COMFORT, + CURRENT_HVAC_IDLE, + ) + + +async def test_off_mode_hvac_off(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data.get("get_zones")[0] + zone.heating.operating_mode = OperatingModes.OFF + await _test_mode_hvac( + hass, + OperatingModes.OFF, + ActiveFunction.STANDBY, + HVAC_MODE_OFF, + Zone.MIN_TARGET_HEATING_TEMP, + PRESET_NONE, + CURRENT_HVAC_IDLE, + ) + + +async def test_quickmode_system_off_mode_hvac_off(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + await _test_mode_hvac( + hass, + QuickModes.SYSTEM_OFF, + ActiveFunction.STANDBY, + HVAC_MODE_OFF, + Zone.MIN_TARGET_HEATING_TEMP, + PRESET_SYSTEM_OFF, + CURRENT_HVAC_IDLE, + ) + + +async def test_quickmode_one_day_away_mode_hvac_off(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + await _test_mode_hvac( + hass, + QuickModes.ONE_DAY_AWAY, + ActiveFunction.STANDBY, + HVAC_MODE_OFF, + Zone.MIN_TARGET_HEATING_TEMP, + PRESET_AWAY, + CURRENT_HVAC_IDLE, + ) + + +async def test_quickmode_party_mode_hvac(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data.get("get_zones")[0] + await _test_mode_hvac( + hass, + QuickModes.PARTY, + ActiveFunction.STANDBY, + HVAC_MODE_OFF, + zone.heating.target_high, + PRESET_PARTY, + CURRENT_HVAC_IDLE, + ) + + +async def test_quickmode_one_day_home_hvac_auto(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data.get("get_zones")[0] + await _test_mode_hvac( + hass, + QuickModes.ONE_DAY_AT_HOME, + ActiveFunction.STANDBY, + HVAC_MODE_AUTO, + zone.heating.target_low, + PRESET_HOME, + CURRENT_HVAC_IDLE, + ) + + +async def test_quickmode_ventilation_boost_hvac_fan(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + await _test_mode_hvac( + hass, + QuickModes.VENTILATION_BOOST, + ActiveFunction.STANDBY, + HVAC_MODE_FAN_ONLY, + Zone.MIN_TARGET_HEATING_TEMP, + PRESET_NONE, + CURRENT_HVAC_IDLE, + ) + + +async def test_holiday_hvac_off(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_holiday_mode"] = active_holiday_mode() + + await _test_mode_hvac( + hass, + QuickModes.HOLIDAY, + ActiveFunction.STANDBY, + HVAC_MODE_OFF, + 15, + PRESET_HOLIDAY, + CURRENT_HVAC_IDLE, + ) + + +async def test_set_hvac_auto(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data["get_zones"][0] + await _test_set_hvac( + hass, + OperatingModes.AUTO, + ActiveFunction.STANDBY, + HVAC_MODE_AUTO, + zone.temperature, + zone.active_mode.target, + PRESET_COMFORT, + CURRENT_HVAC_IDLE, + ) + + +async def test_set_hvac_off(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data["get_zones"][0] + await _test_set_hvac( + hass, + OperatingModes.OFF, + ActiveFunction.STANDBY, + HVAC_MODE_OFF, + zone.temperature, + Zone.MIN_TARGET_HEATING_TEMP, + PRESET_NONE, + CURRENT_HVAC_IDLE, + ) + + +async def test_set_target_temp_cool(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data["get_zones"][0] + zone.active_function = ActiveFunction.STANDBY + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "climate", + "set_temperature", + {"entity_id": "climate.zone_1", "temperature": 14}, + ) + + _assert_zone_state( + hass, + OperatingModes.QUICK_VETO, + HVAC_MODE_OFF, + zone.temperature, + 14, + PRESET_QUICK_VETO, + CURRENT_HVAC_IDLE, + ) + SystemManagerMock.instance.set_zone_quick_veto.assert_called_once() + SystemManagerMock.instance.remove_quick_mode.assert_called_once() + SystemManagerMock.instance.remove_holiday_mode.assert_called_once() + + +async def test_set_target_temp_heat(hass): + """Test mode <> hvac.""" + SystemManagerMock.init_defaults() + zone = SystemManagerMock.data["get_zones"][0] + zone.active_function = ActiveFunction.HEATING + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "climate", + "set_temperature", + {"entity_id": "climate.zone_1", "temperature": 30}, + ) + + _assert_zone_state( + hass, + OperatingModes.QUICK_VETO, + HVAC_MODE_HEAT, + zone.temperature, + 30, + PRESET_QUICK_VETO, + CURRENT_HVAC_HEAT, + ) + SystemManagerMock.instance.set_zone_quick_veto.assert_called_once() + + +async def test_state_update_zone(hass): + """Test zone climate is updated accordingly to data.""" + assert await setup_multimatic(hass) + zone = SystemManagerMock.data["get_zones"][0] + _assert_zone_state( + hass, + OperatingModes.AUTO, + HVAC_MODE_AUTO, + zone.temperature, + zone.active_mode.target, + PRESET_COMFORT, + CURRENT_HVAC_HEAT, + ) + + zone = SystemManagerMock.data["get_zones"][0] + zone.heating.target_high = 30 + zone.heating.time_program = time_program(SettingModes.DAY, None) + zone.temperature = 25 + zone.active_function = ActiveFunction.HEATING + await goto_future(hass) + + _assert_zone_state( + hass, + OperatingModes.AUTO, + HVAC_MODE_AUTO, + 25, + 30, + PRESET_COMFORT, + CURRENT_HVAC_HEAT, + ) + + +async def test_cooling_for_x_days(hass): + """Test zone climate is updated accordingly to data.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_quick_mode"] = QuickModes.get( + QuickModes.COOLING_FOR_X_DAYS.name, 3 + ) + zone = SystemManagerMock.data["get_zones"][0] + zone.cooling = ZoneCooling( + time_program=time_program(SettingModes.NIGHT, None), + operating_mode=OperatingModes.AUTO, + target_low=20, + target_high=22, + ) + zone.active_function = ActiveFunction.COOLING + + assert await setup_multimatic(hass, with_defaults=False) + zone = SystemManagerMock.data["get_zones"][0] + _assert_zone_state( + hass, + OperatingModes.AUTO, + HVAC_MODE_COOL, + zone.temperature, + 22, + PRESET_COOLING_FOR_X_DAYS, + CURRENT_HVAC_COOL, + ) + assert ( + hass.states.get("climate.zone_1").attributes["cooling_for_x_days_duration"] == 3 + ) diff --git a/tests/components/multimatic/test_config_flow.py b/tests/components/multimatic/test_config_flow.py new file mode 100644 index 00000000000000..55a8d456d3032e --- /dev/null +++ b/tests/components/multimatic/test_config_flow.py @@ -0,0 +1,57 @@ +"""Test the multimatic config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.multimatic.const import DOMAIN +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.multimatic.config_flow.validate_authentication", + return_value=True, + ), patch( + "homeassistant.components.multimatic.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.multimatic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + await hass.async_block_till_done() + + print(result2) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Multimatic" + assert result2["data"] == {"username": "test-username", "password": "test-password"} + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pymultimatic.systemmanager.SystemManager.login", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/multimatic/test_coordinator.py b/tests/components/multimatic/test_coordinator.py new file mode 100644 index 00000000000000..20b406e1192e2f --- /dev/null +++ b/tests/components/multimatic/test_coordinator.py @@ -0,0 +1,32 @@ +"""Test coordinator mechanism.""" +from pymultimatic.api import ApiError +import pytest + +from homeassistant.components import multimatic +from homeassistant.components.multimatic.const import COORDINATORS, DOMAIN, ROOMS + +from tests.components.multimatic import ( + SystemManagerMock, + assert_entities_count, + setup_multimatic, +) + + +@pytest.fixture(autouse=True) +def fixture_only_climate(mock_manager): + """Mock multimatic to only handle sensor.""" + orig_platforms = multimatic.PLATFORMS + multimatic.PLATFORMS = ["climate"] + yield + multimatic.PLATFORMS = orig_platforms + + +async def test_error_409(hass): + """Test setup with valid config.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_rooms"] = ApiError("test", "resp", 409) + assert await setup_multimatic(hass, with_defaults=False) + assert_entities_count(hass, 1) + entry_id = hass.config_entries.async_entries(DOMAIN)[0].unique_id + coordinator = hass.data[DOMAIN][entry_id][COORDINATORS][ROOMS] + assert coordinator.update_method.__name__ == "_fetch_data_if_needed" diff --git a/tests/components/multimatic/test_event.py b/tests/components/multimatic/test_event.py new file mode 100644 index 00000000000000..ab341705f337fa --- /dev/null +++ b/tests/components/multimatic/test_event.py @@ -0,0 +1,44 @@ +"""Event serialization test.""" +import datetime + +from pymultimatic.model import HolidayMode, QuickModes + +from homeassistant.components.multimatic.utils import ( + holiday_mode_from_json, + holiday_mode_to_json, + quick_mode_from_json, + quick_mode_to_json, +) + + +def test_event_serialize_deserialize(): + """Test event json serialization deseralization.""" + quick_mode = QuickModes.get(QuickModes.COOLING_FOR_X_DAYS.name, 5) + holiday_mode = HolidayMode(False, None, None, None) + + assert quick_mode == quick_mode_from_json(quick_mode_to_json(quick_mode)) + assert quick_mode.duration == 5 + assert holiday_mode == holiday_mode_from_json(holiday_mode_to_json(holiday_mode)) + + +def test_event_serialize_deserialize_none(): + """Test event json serialization deseralization.""" + quick_mode = None + holiday_mode = None + + assert quick_mode == quick_mode_from_json(quick_mode_to_json(quick_mode)) + assert HolidayMode(False) == holiday_mode_from_json( + holiday_mode_to_json(holiday_mode) + ) + + +def test_event_serialize_deserialize_holiday_mode(): + """Test event json serialization deseralization.""" + start = datetime.date.today() - datetime.timedelta(days=1) + end = datetime.date.today() + datetime.timedelta(days=1) + holiday_mode = HolidayMode(True, start, end, 15) + + new_holiday_mode = holiday_mode_from_json(holiday_mode_to_json(holiday_mode)) + assert holiday_mode.target == new_holiday_mode.target + assert holiday_mode.start_date == new_holiday_mode.start_date + assert holiday_mode.end_date == new_holiday_mode.end_date diff --git a/tests/components/multimatic/test_fan.py b/tests/components/multimatic/test_fan.py new file mode 100644 index 00000000000000..5dc50c97ad5354 --- /dev/null +++ b/tests/components/multimatic/test_fan.py @@ -0,0 +1,74 @@ +"""Tests for the multimatic fan.""" +from pymultimatic.model import OperatingModes, QuickModes +import pytest + +import homeassistant.components.multimatic as multimatic + +from tests.components.multimatic import ( + SystemManagerMock, + assert_entities_count, + call_service, + setup_multimatic, +) + + +@pytest.fixture(autouse=True) +def fixture_only_fan(mock_manager): + """Mock multimatic to only handle fan.""" + orig_platforms = multimatic.PLATFORMS + multimatic.PLATFORMS = ["fan"] + yield + multimatic.PLATFORMS = orig_platforms + + +async def test_valid_config(hass): + """Test setup with valid config.""" + assert await setup_multimatic(hass) + assert_entities_count(hass, 1) + assert hass.states.is_state("fan.ventilation", "on") + + +async def test_turn_on(hass): + """Test turn on.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_ventilation"].operating_mode = OperatingModes.NIGHT + assert await setup_multimatic(hass, with_defaults=False) + + await call_service(hass, "fan", "turn_on", {"entity_id": "fan.ventilation"}) + + SystemManagerMock.instance.set_ventilation_operating_mode.assert_called_once() + assert hass.states.is_state("fan.ventilation", "on") + + +async def test_turn_off(hass): + """Test turn off.""" + assert await setup_multimatic(hass) + + await call_service(hass, "fan", "turn_off", {"entity_id": "fan.ventilation"}) + + SystemManagerMock.instance.set_ventilation_operating_mode.assert_called_once() + assert hass.states.is_state("fan.ventilation", "off") + + +async def test_set_preset(hass): + """Test set speed.""" + assert await setup_multimatic(hass) + + await call_service( + hass, + "fan", + "set_preset_mode", + {"entity_id": "fan.ventilation", "preset_mode": "AUTO"}, + ) + + SystemManagerMock.instance.set_ventilation_operating_mode.assert_called_once() + assert hass.states.is_state("fan.ventilation", "on") + + +async def test_boost_quick_mode(hass): + """Test with quick boost.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_quick_mode"] = QuickModes.VENTILATION_BOOST + assert await setup_multimatic(hass, with_defaults=False) + + assert hass.states.is_state("fan.ventilation", "on") diff --git a/tests/components/multimatic/test_sensor.py b/tests/components/multimatic/test_sensor.py new file mode 100644 index 00000000000000..6cf4ec1c6dae3a --- /dev/null +++ b/tests/components/multimatic/test_sensor.py @@ -0,0 +1,57 @@ +"""Tests for the multimatic sensor.""" + +import pytest + +import homeassistant.components.multimatic as multimatic + +from tests.components.multimatic import ( + SystemManagerMock, + assert_entities_count, + goto_future, + setup_multimatic, +) + + +@pytest.fixture(autouse=True) +def fixture_only_sensor(mock_manager): + """Mock multimatic to only handle sensor.""" + orig_platforms = multimatic.PLATFORMS + multimatic.PLATFORMS = ["sensor"] + yield + multimatic.PLATFORMS = orig_platforms + + +async def test_valid_config(hass): + """Test setup with valid config.""" + assert await setup_multimatic(hass) + assert_entities_count(hass, 3) + + +async def test_empty_system(hass): + """Test setup with empty system.""" + assert await setup_multimatic(hass, with_defaults=False, data={}) + assert_entities_count(hass, 0) + + +async def test_state_update(hass): + """Test all sensors are updated accordingly to data.""" + assert await setup_multimatic(hass) + assert_entities_count(hass, 3) + + assert hass.states.is_state("sensor.waterpressuresensor", "1.9") + assert hass.states.is_state("sensor.outdoor_temperature", "18") + assert hass.states.is_state( + "sensor.flexotherm_pr_ebus_cooling_consumed_electrical_power", "1000" + ) + + SystemManagerMock.data["get_outdoor_temperature"] = 21 + SystemManagerMock.data["get_live_reports"][0].value = 1.6 + SystemManagerMock.data["get_emf_devices"][0].value = 2000 + + await goto_future(hass) + + assert hass.states.is_state("sensor.waterpressuresensor", "1.6") + assert hass.states.is_state("sensor.outdoor_temperature", "21") + assert hass.states.is_state( + "sensor.flexotherm_pr_ebus_cooling_consumed_electrical_power", "2000" + ) diff --git a/tests/components/multimatic/test_service.py b/tests/components/multimatic/test_service.py new file mode 100644 index 00000000000000..fa088f35663210 --- /dev/null +++ b/tests/components/multimatic/test_service.py @@ -0,0 +1,300 @@ +"""Tests for service.""" +from datetime import datetime + +from pymultimatic.model import QuickModes, QuickVeto +import pytest +import voluptuous + +from homeassistant.components import multimatic +from homeassistant.components.multimatic import DOMAIN + +from tests.components.multimatic import ( + SystemManagerMock, + active_holiday_mode, + call_service, + setup_multimatic, +) + + +@pytest.fixture(autouse=True) +def fixture_only_climate(mock_manager): + """Mock multimatic to only handle services.""" + orig_platforms = multimatic.PLATFORMS + multimatic.PLATFORMS = ["climate", "fan"] + yield + multimatic.PLATFORMS = orig_platforms + + +async def test_valid_config(hass): + """Test setup with valid config.""" + assert await setup_multimatic(hass) + assert len(hass.services.async_services()[DOMAIN]) == 10 + + +async def test_remove_quick_mode(hass): + """Test remove existing quick mode.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_quick_mode"] = QuickModes.ONE_DAY_AT_HOME + assert await setup_multimatic(hass, with_defaults=False) + await call_service(hass, "multimatic", "remove_quick_mode", None) + SystemManagerMock.instance.remove_quick_mode.assert_called_once_with() + + +async def test_remove_quick_mode_wrong_data(hass): + """Test remove existing quick mode with wrong data.""" + assert await setup_multimatic(hass) + with pytest.raises(voluptuous.error.MultipleInvalid): + await call_service(hass, "multimatic", "remove_quick_mode", {"test": "boom"}) + + +async def test_remove_holiday_mode(hass): + """Remove existing holiday mode.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_holiday_mode"] = active_holiday_mode() + assert await setup_multimatic(hass) + await call_service(hass, "multimatic", "remove_holiday_mode", None) + SystemManagerMock.instance.remove_holiday_mode.assert_called_once_with() + + +async def test_remove_holiday_mode_wrong_data(hass): + """Remove existing holiday mode with wrong data.""" + assert await setup_multimatic(hass) + with pytest.raises(voluptuous.error.MultipleInvalid): + await call_service(hass, "multimatic", "remove_holiday_mode", {"test": "boom"}) + + +async def test_set_quick_mode(hass): + """Set quick mode.""" + assert await setup_multimatic(hass) + await call_service(hass, "multimatic", "set_quick_mode", {"quick_mode": "QM_PARTY"}) + SystemManagerMock.instance.set_quick_mode.assert_called_once_with(QuickModes.PARTY) + + +async def test_set_quick_mode_wrong_data(hass): + """Set quick mode with wrong data.""" + assert await setup_multimatic(hass) + with pytest.raises(voluptuous.error.MultipleInvalid): + await call_service(hass, "multimatic", "set_quick_mode", {"test": "boom"}) + + +async def test_set_holiday_mode_correct_date(hass): + """Test holiday mode.""" + assert await setup_multimatic(hass) + await call_service( + hass, + "multimatic", + "set_holiday_mode", + {"start_date": "2010-10-25", "end_date": "2010-10-26", "temperature": "10"}, + ) + SystemManagerMock.instance.set_holiday_mode.assert_called_once() + + +async def test_set_holiday_mode_wrong_date_format(hass): + """Test holiday mode.""" + assert await setup_multimatic(hass) + await call_service( + hass, + "multimatic", + "set_holiday_mode", + { + "start_date": "2010-10-25T00:00:00.000Z", + "end_date": "2010-10-26T00:00:00.000Z", + "temperature": "10", + }, + ) + SystemManagerMock.instance.set_holiday_mode.assert_called_once() + + +async def test_set_holiday_mode_wrong_data(hass): + """Test holiday mode with wrong data.""" + assert await setup_multimatic(hass) + with pytest.raises(voluptuous.error.MultipleInvalid): + await call_service(hass, "multimatic", "set_holiday_mode", {"test": "boom"}) + + +async def test_remove_quick_veto_wrong_data(hass): + """Remove quick veto with wrong entity id.""" + assert await setup_multimatic(hass) + await call_service( + hass, "multimatic", "remove_quick_veto", {"entity_id": "climate.test123"} + ) + SystemManagerMock.instance.remove_room_quick_veto.assert_not_called() + SystemManagerMock.instance.remove_zone_quick_veto.assert_not_called() + + +async def test_remove_quick_veto_room(hass): + """Remove quick veto with already existing quick veto.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_rooms"][0].quick_veto = QuickVeto( + duration=30, target=10 + ) + assert await setup_multimatic(hass, with_defaults=False) + await call_service( + hass, "multimatic", "remove_quick_veto", {"entity_id": "climate.room_1"} + ) + SystemManagerMock.instance.remove_room_quick_veto.assert_called_once_with("1") + + +async def test_no_remove_quick_veto_room(hass): + """Remove quick veto without quick veto.""" + assert await setup_multimatic(hass) + await call_service( + hass, "multimatic", "remove_quick_veto", {"entity_id": "climate.room_1"} + ) + SystemManagerMock.instance.remove_room_quick_veto.assert_not_called() + + +async def test_no_remove_quick_veto_zone(hass): + """Remove quick veto without quick veto.""" + assert await setup_multimatic(hass) + await call_service( + hass, "multimatic", "remove_quick_veto", {"entity_id": "climate.zone_1"} + ) + SystemManagerMock.instance.remove_zone_quick_veto.assert_not_called() + + +async def test_remove_quick_veto_zone(hass): + """Remove quick veto with already existing quick veto.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_zones"][0].quick_veto = QuickVeto( + duration=30, target=10 + ) + assert await setup_multimatic(hass, with_defaults=False) + await call_service( + hass, "multimatic", "remove_quick_veto", {"entity_id": "climate.zone_1"} + ) + SystemManagerMock.instance.remove_zone_quick_veto.assert_called_once_with("zone_1") + + +async def test_set_quick_veto_room(hass): + """Set quick veto without quick veto.""" + assert await setup_multimatic(hass) + await call_service( + hass, + DOMAIN, + "set_quick_veto", + {"entity_id": "climate.room_1", "duration": 300, "temperature": 25}, + ) + SystemManagerMock.instance.set_room_quick_veto.assert_called_once_with( + "1", QuickVeto(300, 25.0) + ) + + +async def test_set_quick_veto_room_with_quick_veto(hass): + """Set quick veto with quick veto.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_rooms"][0].quick_veto = QuickVeto( + duration=30, target=10 + ) + assert await setup_multimatic(hass, with_defaults=False) + await call_service( + hass, + DOMAIN, + "set_quick_veto", + {"entity_id": "climate.room_1", "duration": 300, "temperature": 25}, + ) + SystemManagerMock.instance.set_room_quick_veto.assert_called_once_with( + "1", QuickVeto(300, 25.0) + ) + SystemManagerMock.instance.remove_room_quick_veto.assert_called_once_with("1") + + +async def test_set_quick_veto_zone_with_quick_veto(hass): + """Set quick veto with quick veto.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_zones"][0].quick_veto = QuickVeto( + duration=30, target=10 + ) + assert await setup_multimatic(hass, with_defaults=False) + await call_service( + hass, + DOMAIN, + "set_quick_veto", + {"entity_id": "climate.zone_1", "duration": 300, "temperature": 25}, + ) + SystemManagerMock.instance.set_zone_quick_veto.assert_called_once_with( + "zone_1", QuickVeto(300, 25.0) + ) + SystemManagerMock.instance.remove_zone_quick_veto.assert_called_once_with("zone_1") + + +async def test_set_quick_veto_zone(hass): + """Set quick veto without quick veto.""" + assert await setup_multimatic(hass) + await call_service( + hass, + DOMAIN, + "set_quick_veto", + {"entity_id": "climate.zone_1", "duration": 300, "temperature": 25}, + ) + SystemManagerMock.instance.set_zone_quick_veto.assert_called_once_with( + "zone_1", QuickVeto(300, 25.0) + ) + + +async def test_set_quick_mode_cooling_for_x_days(hass): + """Test cooling for x days quick mode.""" + assert await setup_multimatic(hass) + await call_service( + hass, + DOMAIN, + "set_quick_mode", + {"quick_mode": "QM_COOLING_FOR_X_DAYS", "duration": 1}, + ) + quick_mode = QuickModes.get("QM_COOLING_FOR_X_DAYS", 1) + SystemManagerMock.instance.set_quick_mode.assert_called_once_with(quick_mode) + + +async def test_set_quick_mode_cooling_for_x_days_no_duration(hass): + """Test cooling for x days quick mode.""" + assert await setup_multimatic(hass) + await call_service( + hass, + DOMAIN, + "set_quick_mode", + {"quick_mode": "QM_COOLING_FOR_X_DAYS"}, + ) + quick_mode = QuickModes.get("QM_COOLING_FOR_X_DAYS", 1) + SystemManagerMock.instance.set_quick_mode.assert_called_once_with(quick_mode) + + +async def test_set_ventilation_day_level(hass): + """Test set ventilation day level.""" + assert await setup_multimatic(hass) + await call_service( + hass, + DOMAIN, + "set_ventilation_day_level", + {"entity_id": "fan.ventilation", "level": 1}, + ) + SystemManagerMock.instance.set_ventilation_day_level.assert_called_once_with( + "ventilation", 1 + ) + + +async def test_set_ventilation_night_level(hass): + """Test set ventilation night level.""" + assert await setup_multimatic(hass) + await call_service( + hass, + DOMAIN, + "set_ventilation_night_level", + {"entity_id": "fan.ventilation", "level": 1}, + ) + SystemManagerMock.instance.set_ventilation_night_level.assert_called_once_with( + "ventilation", 1 + ) + + +async def test_set_date_time(hass): + """Test set date time service.""" + assert await setup_multimatic(hass) + dt = datetime.now() + await call_service( + hass, + DOMAIN, + "set_datetime", + {"datetime": dt}, + ) + + SystemManagerMock.instance.set_datetime.assert_called_once_with(dt) diff --git a/tests/components/multimatic/test_water_heater.py b/tests/components/multimatic/test_water_heater.py new file mode 100644 index 00000000000000..5973a71e2258f9 --- /dev/null +++ b/tests/components/multimatic/test_water_heater.py @@ -0,0 +1,302 @@ +"""Tests for the multimatic sensor.""" +import datetime +from unittest.mock import ANY + +from pymultimatic.model import ( + HolidayMode, + HotWater, + OperatingModes, + QuickModes, + constants, +) +import pytest + +import homeassistant.components.multimatic as multimatic + +from tests.components.multimatic import ( + SystemManagerMock, + assert_entities_count, + call_service, + goto_future, + setup_multimatic, +) + + +def _assert_state(hass, mode, temp, current_temp, away_mode): + assert_entities_count(hass, 1) + + state = hass.states.get("water_heater.dhw") + assert hass.states.is_state("water_heater.dhw", mode.name) + assert state.attributes["min_temp"] == HotWater.MIN_TARGET_TEMP + assert state.attributes["max_temp"] == HotWater.MAX_TARGET_TEMP + assert state.attributes["temperature"] == temp + assert state.attributes["current_temperature"] == current_temp + + if mode == QuickModes.HOLIDAY: + assert len(state.attributes.get("operation_list")) == 0 + else: + assert set(state.attributes["operation_list"]) == {"ON", "OFF", "AUTO"} + assert state.attributes["operation_mode"] == mode.name + assert state.attributes["away_mode"] == away_mode + + +@pytest.fixture(autouse=True) +def fixture_only_water_heater(mock_manager): + """Mock vaillant to only handle sensor.""" + orig_platforms = multimatic.PLATFORMS + multimatic.PLATFORMS = ["water_heater"] + yield + multimatic.PLATFORMS = orig_platforms + + +async def test_valid_config(hass): + """Test setup with valid config.""" + assert await setup_multimatic(hass) + _assert_state(hass, OperatingModes.AUTO, HotWater.MIN_TARGET_TEMP, 45, "off") + + +async def test_empty_system(hass): + """Test setup with empty system.""" + assert await setup_multimatic(hass, with_defaults=False, data={}) + assert_entities_count(hass, 0) + + +async def test_state_update(hass): + """Test water heater is updated accordingly to data.""" + assert await setup_multimatic(hass) + _assert_state(hass, OperatingModes.AUTO, HotWater.MIN_TARGET_TEMP, 45, "off") + dhw = SystemManagerMock.data["get_dhw"] + SystemManagerMock.data["DomesticHotWaterTankTemperature"].value = 65 + dhw.hotwater.operating_mode = OperatingModes.ON + dhw.hotwater.target_high = 45 + await goto_future(hass) + + _assert_state(hass, OperatingModes.ON, 45, 65, "off") + + +async def test_holiday_mode(hass): + """Test holiday mode.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_holiday_mode"] = HolidayMode( + True, datetime.date.today(), datetime.date.today(), 15 + ) + SystemManagerMock.data["get_quick_mode"] = QuickModes.HOLIDAY + + assert await setup_multimatic(hass, with_defaults=False) + _assert_state(hass, QuickModes.HOLIDAY, constants.FROST_PROTECTION_TEMP, 45, "on") + + +async def test_away_mode(hass): + """Test away mode.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_dhw"].hotwater.operating_mode = OperatingModes.OFF + + assert await setup_multimatic(hass, with_defaults=False) + _assert_state(hass, OperatingModes.OFF, constants.FROST_PROTECTION_TEMP, 45, "on") + + +async def test_water_boost(hass): + """Test hot water boost mode.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_quick_mode"] = QuickModes.HOTWATER_BOOST + + assert await setup_multimatic(hass, with_defaults=False) + _assert_state(hass, QuickModes.HOTWATER_BOOST, 40, 45, "off") + + +async def test_system_off(hass): + """Test system off mode.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_quick_mode"] = QuickModes.SYSTEM_OFF + + assert await setup_multimatic(hass, with_defaults=False) + _assert_state( + hass, QuickModes.SYSTEM_OFF, constants.FROST_PROTECTION_TEMP, 45, "on" + ) + + +async def test_one_day_away(hass): + """Test one day away mode.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_quick_mode"] = QuickModes.ONE_DAY_AWAY + + assert await setup_multimatic(hass, with_defaults=False) + _assert_state( + hass, QuickModes.ONE_DAY_AWAY, constants.FROST_PROTECTION_TEMP, 45, "on" + ) + + +async def test_turn_away_mode_on(hass): + """Test turn away mode on.""" + assert await setup_multimatic(hass) + + SystemManagerMock.data["get_dhw"].hotwater.operating_mode = OperatingModes.OFF + + await hass.services.async_call( + "water_heater", + "set_away_mode", + {"entity_id": "water_heater.dhw", "away_mode": True}, + ) + await hass.async_block_till_done() + + SystemManagerMock.instance.set_hot_water_operating_mode.assert_called_once_with( + ANY, OperatingModes.OFF + ) + _assert_state(hass, OperatingModes.OFF, constants.FROST_PROTECTION_TEMP, 45, "on") + + +async def test_turn_away_mode_off(hass): + """Test turn away mode off.""" + assert await setup_multimatic(hass) + + SystemManagerMock.data["get_dhw"].hotwater.operating_mode = OperatingModes.AUTO + + await hass.services.async_call( + "water_heater", + "set_away_mode", + {"entity_id": "water_heater.dhw", "away_mode": False}, + ) + await hass.async_block_till_done() + + SystemManagerMock.instance.set_hot_water_operating_mode.assert_called_once_with( + ANY, OperatingModes.AUTO + ) + + _assert_state(hass, OperatingModes.AUTO, HotWater.MIN_TARGET_TEMP, 45, "off") + + +async def test_set_operating_mode(hass): + """Test set operation mode.""" + assert await setup_multimatic(hass) + + SystemManagerMock.data["get_dhw"].hotwater.operating_mode = OperatingModes.ON + + await hass.services.async_call( + "water_heater", + "set_operation_mode", + {"entity_id": "water_heater.dhw", "operation_mode": "ON"}, + ) + await hass.async_block_till_done() + + SystemManagerMock.instance.set_hot_water_operating_mode.assert_called_once_with( + ANY, OperatingModes.ON + ) + _assert_state(hass, OperatingModes.ON, 40, 45, "off") + + +async def test_set_operating_mode_wrong(hass): + """Test set operation mode with wrong mode.""" + assert await setup_multimatic(hass) + + await hass.services.async_call( + "water_heater", + "set_operation_mode", + {"entity_id": "water_heater.dhw", "operation_mode": "wrong"}, + ) + await hass.async_block_till_done() + + SystemManagerMock.instance.set_hot_water_operating_mode.assert_not_called() + _assert_state(hass, OperatingModes.AUTO, HotWater.MIN_TARGET_TEMP, 45, "off") + + +async def test_set_temperature(hass): + """Test set target temperature.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_dhw"].hotwater.operating_mode = OperatingModes.AUTO + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "water_heater", + "set_temperature", + {"entity_id": "water_heater.dhw", "temperature": 50}, + ) + + SystemManagerMock.instance.set_hot_water_setpoint_temperature.assert_called_once_with( + "dhw", 50 + ) + SystemManagerMock.instance.set_hot_water_operating_mode.assert_not_called() + + +async def test_set_temperature_already_on(hass): + """Test set target temperature.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_dhw"].hotwater.operating_mode = OperatingModes.ON + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "water_heater", + "set_temperature", + {"entity_id": "water_heater.dhw", "temperature": 50}, + ) + + SystemManagerMock.instance.set_hot_water_setpoint_temperature.assert_called_once_with( + "dhw", 50 + ) + SystemManagerMock.instance.set_hot_water_operating_mode.assert_not_called() + + +async def test_set_temperature_already_off(hass): + """Test set target temperature.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_dhw"].hotwater.operating_mode = OperatingModes.OFF + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "water_heater", + "set_temperature", + {"entity_id": "water_heater.dhw", "temperature": 50}, + ) + + SystemManagerMock.instance.set_hot_water_setpoint_temperature.assert_called_once_with( + "dhw", 50 + ) + SystemManagerMock.instance.set_hot_water_operating_mode.assert_called_once() + + +async def test_set_operating_mode_while_quick_mode(hass): + """Ensure water heater mode is set when unrelated quick mode is active.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_quick_mode"] = QuickModes.PARTY + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "water_heater", + "set_operation_mode", + {"entity_id": "water_heater.dhw", "operation_mode": "AUTO"}, + ) + SystemManagerMock.instance.set_hot_water_operating_mode.assert_called_once_with( + "dhw", OperatingModes.AUTO + ) + + +async def test_set_operating_mode_while_quick_mode_for_dhw(hass): + """Ensure water heater mode is not set when related quick mode is active.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["get_quick_mode"] = QuickModes.HOTWATER_BOOST + assert await setup_multimatic(hass, with_defaults=False) + + await call_service( + hass, + "water_heater", + "set_operation_mode", + {"entity_id": "water_heater.dhw", "operation_mode": "AUTO"}, + ) + SystemManagerMock.instance.set_hot_water_operating_mode.assert_called_once_with( + "dhw", OperatingModes.AUTO + ) + SystemManagerMock.instance.remove_quick_mode.assert_called_once_with() + + +async def test_direct_water_heater_no_tank(hass): + """Test for direct water heater without tank.""" + SystemManagerMock.init_defaults() + SystemManagerMock.data["DomesticHotWaterTankTemperature"] = None + SystemManagerMock.data["get_dhw"].hotwater.time_program = None + + assert await setup_multimatic(hass, with_defaults=False) + + _assert_state(hass, OperatingModes.AUTO, 40, None, "off")