From 4de17d6a6392c1f3dfba880cb5de572740e6f512 Mon Sep 17 00:00:00 2001 From: thomasgermain <12560542+thomasgermain@users.noreply.github.com> Date: Tue, 29 Jun 2021 20:29:41 +0200 Subject: [PATCH] 1.7.0b2 --- custom_components/multimatic/__init__.py | 43 +- custom_components/multimatic/binary_sensor.py | 285 ++++++------ custom_components/multimatic/climate.py | 126 +++--- custom_components/multimatic/config_flow.py | 23 +- custom_components/multimatic/const.py | 37 +- custom_components/multimatic/coordinator.py | 389 +++++++++------- custom_components/multimatic/entities.py | 44 +- custom_components/multimatic/fan.py | 50 +-- custom_components/multimatic/hub.py | 418 ------------------ custom_components/multimatic/manifest.json | 4 +- custom_components/multimatic/sensor.py | 47 +- custom_components/multimatic/service.py | 18 +- custom_components/multimatic/utils.py | 97 ++-- custom_components/multimatic/water_heater.py | 36 +- 14 files changed, 654 insertions(+), 963 deletions(-) delete mode 100644 custom_components/multimatic/hub.py diff --git a/custom_components/multimatic/__init__.py b/custom_components/multimatic/__init__.py index afdc967..0097760 100644 --- a/custom_components/multimatic/__init__.py +++ b/custom_components/multimatic/__init__.py @@ -1,13 +1,21 @@ """The multimatic integration.""" import asyncio +from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from .const import COORDINATOR, DOMAIN, PLATFORMS, SERVICES_HANDLER -from .coordinator import MultimaticDataUpdateCoordinator +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__) @@ -21,20 +29,37 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up multimatic from a config entry.""" - api: MultimaticDataUpdateCoordinator = MultimaticDataUpdateCoordinator(hass, entry) - await api.authenticate() - await api.async_refresh() + api: MultimaticApi = MultimaticApi(hass, entry) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(entry.unique_id, {}) - hass.data[DOMAIN][entry.unique_id][COORDINATOR] = api + 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 + await m_coord.async_config_entry_first_refresh() + _LOGGER.debug("Adding %s coordinator", m_coord.name) for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, platform) ) - async def logout(param): + async def logout(event): await api.logout() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, logout) @@ -44,7 +69,7 @@ async def logout(param): return True -async def async_setup_service(api: MultimaticDataUpdateCoordinator, hass): +async def async_setup_service(api: MultimaticApi, hass): """Set up services.""" if not hass.data.get(SERVICES_HANDLER): service_handler = MultimaticServiceHandler(api, hass) diff --git a/custom_components/multimatic/binary_sensor.py b/custom_components/multimatic/binary_sensor.py index c828be1..e1d24de 100644 --- a/custom_components/multimatic/binary_sensor.py +++ b/custom_components/multimatic/binary_sensor.py @@ -1,4 +1,5 @@ """Interfaces with Multimatic binary sensors.""" +from __future__ import annotations import logging @@ -16,9 +17,19 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.util import slugify -from . import MultimaticDataUpdateCoordinator -from .const import COORDINATOR, DOMAIN as MULTIMATIC +from .const import ( + 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__) @@ -26,34 +37,36 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the multimatic binary sensor platform.""" sensors = [] - coordinator: MultimaticDataUpdateCoordinator = hass.data[MULTIMATIC][ - entry.unique_id - ][COORDINATOR] - if coordinator.data: - if coordinator.data.dhw and coordinator.data.dhw.circulation: - sensors.append(CirculationSensor(coordinator)) - - if coordinator.data.boiler_status: - sensors.append(BoilerStatus(coordinator)) - - if coordinator.data.info: - sensors.append(BoxOnline(coordinator)) - sensors.append(BoxUpdate(coordinator)) - - for room in coordinator.data.rooms: - sensors.append(RoomWindow(coordinator, room)) - for device in room.devices: - if device.device_type == "VALVE": - sensors.append(RoomDeviceChildLock(coordinator, device, room)) - sensors.append(RoomDeviceBattery(coordinator, device)) - sensors.append(RoomDeviceConnectivity(coordinator, device)) + 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(coordinator), - QuickModeSensor(coordinator), - MultimaticErrors(coordinator), + HolidayModeSensor(get_coordinator(hass, HOLIDAY_MODE, entry.unique_id)), + QuickModeSensor(get_coordinator(hass, QUICK_MODE, entry.unique_id)), ] ) @@ -66,10 +79,9 @@ async def async_setup_entry(hass, entry, async_add_entities): class CirculationSensor(MultimaticEntity, BinarySensorEntity): """Binary sensor for circulation running on or not.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + def __init__(self, coordinator: MultimaticCoordinator) -> None: """Initialize entity.""" super().__init__(coordinator, DOMAIN, "dhw_circulation") - self._name = coordinator.data.dhw.circulation.name @property def is_on(self): @@ -85,27 +97,25 @@ def available(self): """Return True if entity is available.""" return ( super().available - and self.coordinator.data.dhw is not None - and self.coordinator.data.dhw.circulation is not None + and self.coordinator.data + and self.coordinator.data.circulation ) @property def active_mode(self): """Return the active mode of the circulation.""" - return self.coordinator.data.get_active_mode_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._name + return self.coordinator.data.circulation.name class RoomWindow(MultimaticEntity, BinarySensorEntity): """multimatic window binary sensor.""" - def __init__( - self, coordinator: MultimaticDataUpdateCoordinator, room: Room - ) -> None: + def __init__(self, coordinator: MultimaticCoordinator, room: Room) -> None: """Initialize entity.""" super().__init__(coordinator, DOMAIN, f"{room.name}_{DEVICE_CLASS_WINDOW}") self._room_id = room.id @@ -118,7 +128,7 @@ def is_on(self): @property def available(self): """Return True if entity is available.""" - return super().available and self.room is not None + return super().available and self.room @property def device_class(self): @@ -131,16 +141,16 @@ def name(self) -> str: return self.room.name if self.room else None @property - def room(self): + def room(self) -> Room: """Return the room.""" - return self.coordinator.get_room(self._room_id) + return self.coordinator.find_component(self._room_id) class RoomDeviceEntity(MultimaticEntity, BinarySensorEntity): """Base class for ambisense device.""" def __init__( - self, coordinator: MultimaticDataUpdateCoordinator, device: Device, extra_id + self, coordinator: MultimaticCoordinator, device: Device, extra_id ) -> None: """Initialize device.""" MultimaticEntity.__init__( @@ -172,12 +182,16 @@ def device_state_attributes(self): @property def available(self): """Return True if entity is available.""" - return super().available and self.device is not None + return super().available and self.device @property def device(self): """Return the device.""" - return self.coordinator.get_room_device(self._sgtin) + for room in self.coordinator.data: + for device in room.devices: + if device.sgtin == self._sgtin: + return device + return None class RoomDeviceChildLock(RoomDeviceEntity): @@ -188,7 +202,7 @@ class RoomDeviceChildLock(RoomDeviceEntity): """ def __init__( - self, coordinator: MultimaticDataUpdateCoordinator, device: Device, room: Room + self, coordinator: MultimaticCoordinator, device: Device, room: Room ) -> None: """Initialize entity.""" super().__init__(coordinator, device, DEVICE_CLASS_LOCK) @@ -202,12 +216,12 @@ def is_on(self): @property def available(self): """Return True if entity is available.""" - return super().available and self.room is not None + return super().available and self.room @property - def room(self): + def room(self) -> Room: """Return the room.""" - return self.coordinator.get_room(self._room_id) + return self.coordinator.find_component(self._room_id) @property def device_class(self): @@ -218,9 +232,7 @@ def device_class(self): class RoomDeviceBattery(RoomDeviceEntity): """Represent a device battery.""" - def __init__( - self, coordinator: MultimaticDataUpdateCoordinator, device: Device - ) -> None: + def __init__(self, coordinator: MultimaticCoordinator, device: Device) -> None: """Initialize entity.""" super().__init__(coordinator, device, DEVICE_CLASS_BATTERY) @@ -238,9 +250,7 @@ def device_class(self): class RoomDeviceConnectivity(RoomDeviceEntity): """Device in room is out of reach or not.""" - def __init__( - self, coordinator: MultimaticDataUpdateCoordinator, device: Device - ) -> None: + def __init__(self, coordinator: MultimaticCoordinator, device: Device) -> None: """Initialize entity.""" super().__init__(coordinator, device, DEVICE_CLASS_CONNECTIVITY) @@ -258,69 +268,77 @@ def device_class(self): class VRBoxEntity(MultimaticEntity, BinarySensorEntity): """multimatic gateway device (ex: VR920).""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator, comp_id): + def __init__( + self, + coord: MultimaticCoordinator, + detail_coo: MultimaticCoordinator, + gw_coo: MultimaticCoordinator, + comp_id, + ): """Initialize entity.""" - MultimaticEntity.__init__(self, coordinator, DOMAIN, comp_id) + 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.""" - return { - "identifiers": {(MULTIMATIC, self.system_info.serial_number)}, - "connections": {(CONNECTION_NETWORK_MAC, self.system_info.mac_ethernet)}, - "name": self.system_info.gateway, - "manufacturer": "Vaillant", - "model": self.system_info.gateway, - "sw_version": self.system_info.firmware, - } - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - "serial_number": self.system_info.serial_number, - "connected": self.system_info.is_online, - "up_to_date": self.system_info.is_up_to_date, - } - - @property - def available(self): - """Return True if entity is available.""" - return super().available and self.system_info is not None - - @property - def system_info(self): - """Return the system information.""" - return self.coordinator.data.info + 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, coordinator: MultimaticDataUpdateCoordinator) -> None: + def __init__( + self, + coord: MultimaticCoordinator, + detail_coo: MultimaticCoordinator, + gw_coo: MultimaticCoordinator, + ) -> None: """Init.""" super().__init__( - coordinator, + 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.info.is_up_to_date + 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" class BoxOnline(VRBoxEntity): """Check if box is online.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + def __init__( + self, + coord: MultimaticCoordinator, + detail_coo: MultimaticCoordinator, + gw_coo: MultimaticCoordinator, + ) -> None: """Init.""" - super().__init__(coordinator, "multimatic_system_online") + 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.info.is_online + return self.coordinator.data.is_online @property def name(self): @@ -336,7 +354,7 @@ def device_class(self): class BoilerStatus(MultimaticEntity, BinarySensorEntity): """Check if there is some error.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + def __init__(self, coordinator: MultimaticCoordinator) -> None: """Initialize entity.""" MultimaticEntity.__init__( self, @@ -344,30 +362,29 @@ def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: DOMAIN, coordinator.data.boiler_status.device_name, ) - self._boiler_id = slugify(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 is not None and self.boiler_status.is_error + return self.boiler_status and self.boiler_status.is_error @property def state_attributes(self): """Return the state attributes.""" - return { - "status_code": self.boiler_status.status_code, - "title": self.boiler_status.title, - "timestamp": self.boiler_status.timestamp, - } + 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, self.coordinator.data.info.serial_number) - }, + "identifiers": {(MULTIMATIC, self._boiler_id)}, "name": self._name, "manufacturer": "Vaillant", "model": self._name, @@ -383,7 +400,7 @@ def device_state_attributes(self): @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.boiler_status is not None + return super().available and self.boiler_status @property def name(self): @@ -393,7 +410,7 @@ def name(self): @property def boiler_status(self): """Return the boiler status.""" - return self.coordinator.data.boiler_status + return self.coordinator.data.boiler_status if self.coordinator.data else None @property def device_class(self): @@ -404,7 +421,7 @@ def device_class(self): class MultimaticErrors(MultimaticEntity, BinarySensorEntity): """Check if there is any error message from system.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + def __init__(self, coordinator: MultimaticCoordinator) -> None: """Init.""" super().__init__( coordinator, @@ -415,31 +432,27 @@ def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: @property def is_on(self): """Return true if the binary sensor is on.""" - return len(self.coordinator.data.errors) > 0 + return self.coordinator.data.errors and len(self.coordinator.data.errors) > 0 @property def state_attributes(self): """Return the state attributes.""" state_attributes = {} - for error in self.coordinator.data.errors: - state_attributes.update( - { - error.status_code: { - "status_code": error.status_code, - "title": error.title, - "timestamp": error.timestamp, - "description": error.description, - "device_name": error.device_name, + if self.coordinator.data.errors: + for error in self.coordinator.data.errors: + state_attributes.update( + { + error.status_code: { + "status_code": error.status_code, + "title": error.title, + "timestamp": error.timestamp, + "description": error.description, + "device_name": error.device_name, + } } - } - ) + ) return state_attributes - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.coordinator.data.errors is not None - @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" @@ -454,62 +467,60 @@ def name(self): class HolidayModeSensor(MultimaticEntity, BinarySensorEntity): """Binary sensor for holiday mode.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + 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.holiday and self.coordinator.data.holiday.is_applied - ) + return self.coordinator.data 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.holiday.start_date.isoformat(), - "end_date": self.coordinator.data.holiday.end_date.isoformat(), - "temperature": self.coordinator.data.holiday.target, + "start_date": self.coordinator.data.start_date.isoformat(), + "end_date": self.coordinator.data.end_date.isoformat(), + "temperature": self.coordinator.data.target, } - @property - def listening(self): - """Return whether this entity is listening for system changes or not.""" - return True - @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 + class QuickModeSensor(MultimaticEntity, BinarySensorEntity): """Binary sensor for holiday mode.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + 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.quick_mode is not None + return self.coordinator.data @property def state_attributes(self): """Return the state attributes.""" if self.is_on: - return {"quick_mode": self.coordinator.data.quick_mode.name} - - @property - def listening(self): - """Return whether this entity is listening for system changes or not.""" - return True + return {"quick_mode": self.coordinator.data.name} @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 diff --git a/custom_components/multimatic/climate.py b/custom_components/multimatic/climate.py index 7fed0a9..975ae58 100644 --- a/custom_components/multimatic/climate.py +++ b/custom_components/multimatic/climate.py @@ -3,6 +3,7 @@ import abc import logging +from typing import Any, Mapping from pymultimatic.model import ( ActiveFunction, @@ -37,11 +38,9 @@ from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers import entity_platform -from . import SERVICES, MultimaticDataUpdateCoordinator +from . import SERVICES from .const import ( - COORDINATOR, DEFAULT_QUICK_VETO_DURATION, - DOMAIN as MULTIMATIC, PRESET_COOLING_FOR_X_DAYS, PRESET_COOLING_ON, PRESET_DAY, @@ -50,9 +49,14 @@ 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__) @@ -66,20 +70,19 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the multimatic climate platform.""" climates = [] - coordinator = hass.data[MULTIMATIC][entry.unique_id][COORDINATOR] + 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 coordinator.data: - if coordinator.data.zones: - for zone in coordinator.data.zones: - if not zone.rbr and zone.enabled: - entity = ZoneClimate(coordinator, zone) - climates.append(entity) + 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 coordinator.data.rooms: - rbr_zone = [zone for zone in coordinator.data.zones if zone.rbr][0] - for room in coordinator.data.rooms: - entity = RoomClimate(coordinator, room, rbr_zone) - climates.append(entity) + if rooms_coo.data: + rbr_zone = [zone for zone in zones_coo.data if zone.rbr][0] + for room in rooms_coo.data: + climates.append(RoomClimate(rooms_coo, zones_coo, room, rbr_zone)) _LOGGER.info("Adding %s climate entities", len(climates)) @@ -106,43 +109,37 @@ class MultimaticClimate(MultimaticEntity, ClimateEntity, abc.ABC): def __init__( self, - coordinator: MultimaticDataUpdateCoordinator, + coordinator: MultimaticCoordinator, comp_id, - component: Component, ): """Initialize entity.""" super().__init__(coordinator, DOMAIN, comp_id) - self._component = component + 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.set_quick_veto(self, temperature, 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.remove_quick_veto(self) + await self.coordinator.api.remove_quick_veto(self) @property - @abc.abstractmethod def active_mode(self) -> ActiveMode: """Get active mode of the climate.""" + return self.coordinator.api.get_active_mode(self.component) @property - def component(self): + @abc.abstractmethod + def component(self) -> Component: """Return the room or the zone.""" - return self.coordinator.find_component(self._component) - - @property - def listening(self): - """Return whether this entity is listening for system changes or not.""" - return True @property def available(self): """Return True if entity is available.""" - return super().available and self.component is not None + return super().available and self.component @property def temperature_unit(self): @@ -239,13 +236,20 @@ class RoomClimate(MultimaticClimate): } def __init__( - self, coordinator: MultimaticDataUpdateCoordinator, room: Room, zone: Zone + self, coordinator: MultimaticCoordinator, zone_coo, room: Room, zone: Zone ) -> None: """Initialize entity.""" - super().__init__(coordinator, room.name, room) + super().__init__(coordinator, room.name) self._zone_id = zone.id + 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 component(self) -> Component: + """Get the component.""" + return self.coordinator.find_component(self._room_id) @property def hvac_mode(self) -> str: @@ -280,26 +284,25 @@ def max_temp(self): """Return the maximum temperature.""" return Room.MAX_TARGET_TEMP - @property - def active_mode(self) -> ActiveMode: - """Get active mode of the climate.""" - return self.coordinator.data.get_active_mode_room(self.component) - @property def zone(self): """Return the zone the current room belongs.""" - return self.coordinator.get_zone(self._zone_id) + if self._zone_coo.data: + 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.set_room_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: str) -> None: """Set new target hvac mode.""" mode = RoomClimate._HA_MODE_TO_MULTIMATIC[hvac_mode] - await self.coordinator.set_room_operating_mode(self, mode) + await self.coordinator.api.set_room_operating_mode(self, mode) @property def preset_mode(self) -> str | None: @@ -322,7 +325,7 @@ def preset_modes(self) -> list[str] | None: 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.set_room_operating_mode(self, mode) + await self.coordinator.api.set_room_operating_mode(self, mode) @property def hvac_action(self) -> str | None: @@ -331,12 +334,18 @@ def hvac_action(self) -> str | None: Need to be one of CURRENT_HVAC_*. """ if ( - self.zone.active_function == ActiveFunction.HEATING + 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.""" + return self.component.humidity + class ZoneClimate(MultimaticClimate): """Climate for a zone.""" @@ -376,10 +385,10 @@ class ZoneClimate(MultimaticClimate): } def __init__( - self, coordinator: MultimaticDataUpdateCoordinator, zone: Zone + self, coordinator: MultimaticCoordinator, zone: Zone, ventilation ) -> None: """Initialize entity.""" - super().__init__(coordinator, zone.id, zone) + 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()) @@ -388,15 +397,25 @@ def __init__( self._supported_presets.remove(PRESET_COOLING_ON) self._supported_presets.remove(PRESET_COOLING_FOR_X_DAYS) - if not coordinator.data.ventilation: + if not ventilation: self._supported_hvac.remove(HVAC_MODE_FAN_ONLY) - self._name = zone.name + self._zone_id = zone.id @property - def name(self) -> str | None: - """Return the name of the entity.""" - return self._name + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + attr = {} + if self.active_mode == QuickModes.COOLING_FOR_X_DAYS: + attr.update( + {"cooling_for_x_days_duration": self.active_mode.current.duration} + ) + return attr + + @property + def component(self) -> Component: + """Return the room.""" + return self.coordinator.find_component(self._zone_id) @property def hvac_mode(self): @@ -447,25 +466,20 @@ def target_temperature(self): """Return the temperature we try to reach.""" return self.active_mode.target - @property - def active_mode(self) -> ActiveMode: - """Get active mode of the climate.""" - return self.coordinator.data.get_active_mode_zone(self.component) - 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.set_zone_target_temperature(self, 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.set_zone_operating_mode(self, mode) + await self.coordinator.api.set_zone_operating_mode(self, mode) @property def hvac_action(self) -> str | None: @@ -490,4 +504,4 @@ def preset_modes(self) -> list[str] | None: 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.set_zone_operating_mode(self, mode) + await self.coordinator.api.set_zone_operating_mode(self, mode) diff --git a/custom_components/multimatic/config_flow.py b/custom_components/multimatic/config_flow.py index cbe14f6..65057d9 100644 --- a/custom_components/multimatic/config_flow.py +++ b/custom_components/multimatic/config_flow.py @@ -2,19 +2,16 @@ 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_get_clientsession import homeassistant.helpers.config_validation as cv -from .const import ( # pylint: disable=unused-import - CONF_SERIAL_NUMBER, - DEFAULT_SCAN_INTERVAL, - DOMAIN, -) -from .coordinator import check_authentication +from .const import CONF_SERIAL_NUMBER, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,21 +30,21 @@ async def validate_input(hass: core.HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - await validate_authentication( - hass, data[CONF_USERNAME], data[CONF_PASSWORD], data.get(CONF_SERIAL_NUMBER) - ) + await validate_authentication(hass, data[CONF_USERNAME], data[CONF_PASSWORD]) return {"title": "Multimatic"} -async def validate_authentication(hass, username, password, serial): +async def validate_authentication(hass, username, password): """Ensure provided credentials are working.""" try: - if not await check_authentication(hass, username, password, serial): + if not await SystemManager( + username, password, async_get_clientsession(hass) + ).login(True): raise InvalidAuth except ApiError as err: - _LOGGER.exception( - "Unable to authenticate %s, status: %s, response: %s", + _LOGGER.error( + "Unable to authenticate: %s, status: %s, response: %s", err.message, err.status, err.response, diff --git a/custom_components/multimatic/const.py b/custom_components/multimatic/const.py index 67dc22e..a1fb60a 100644 --- a/custom_components/multimatic/const.py +++ b/custom_components/multimatic/const.py @@ -1,6 +1,9 @@ """multimatic integration constants.""" -# constants used in hass.data +from __future__ import annotations + +from datetime import timedelta + DOMAIN = "multimatic" ENTITIES = "entities" @@ -33,8 +36,6 @@ CONF_SERIAL_NUMBER = "serial_number" # constants for states_attributes -# ATTR_MULTIMATIC_SETTING = "setting" -# ATTR_ENDS_AT = "ends_at" ATTR_QUICK_MODE = "quick_mode" ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" @@ -42,6 +43,32 @@ ATTR_DURATION = "duration" SERVICES_HANDLER = "services_handler" -COORDINATOR = "coordinator" -REFRESH_ENTITIES_EVENT = "multimatic_refresh_entities" +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" +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), +} diff --git a/custom_components/multimatic/coordinator.py b/custom_components/multimatic/coordinator.py index c259954..e70e8d7 100644 --- a/custom_components/multimatic/coordinator.py +++ b/custom_components/multimatic/coordinator.py @@ -1,10 +1,13 @@ """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, @@ -13,77 +16,135 @@ QuickModes, QuickVeto, Room, - System, + 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_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_SERIAL_NUMBER, DEFAULT_QUICK_VETO_DURATION, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SMART_PHONE_ID, - DOMAIN, - REFRESH_ENTITIES_EVENT, + 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__) -async def check_authentication(hass, username, password, serial): - """Check if provided username an password are corrects.""" - return await pymultimatic.systemmanager.SystemManager( - username, - password, - async_get_clientsession(hass), - DEFAULT_SMART_PHONE_ID, - serial, - ).login(True) - - -class MultimaticDataUpdateCoordinator(DataUpdateCoordinator[System]): - """multimatic entry point for home-assistant.""" +class MultimaticApi: + """Utility to interact with multimatic API.""" def __init__(self, hass, entry: ConfigEntry): - """Initialize hub.""" + """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] - serial = entry.data.get(CONF_SERIAL_NUMBER) - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta( - minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ), - update_method=self._fetch_data, - ) self._manager = pymultimatic.systemmanager.SystemManager( user=username, password=password, session=async_get_clientsession(hass), - serial=serial, + serial=self.serial, ) - self.fixed_serial = serial is not None + self._quick_mode: QuickMode | None = None + self._holiday_mode: HolidayMode | None = None + self._hass = hass - async def authenticate(self): - """Try to authenticate to the API.""" - try: - return await self._manager.login(True) - except ApiError as err: - await self._log_error(err) - return False + 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 hvac status") + return await self._manager.get_hvac_status() async def request_hvac_update(self): """Request is not on the classic update since it won't fetch data. @@ -95,91 +156,16 @@ async def request_hvac_update(self): _LOGGER.debug("Will request_hvac_update") await self._manager.request_hvac_update() except ApiError as err: - if err.status == 409: - _LOGGER.warning("Request_hvac_update is done too often") - else: - await self._log_error(err) - await self.authenticate() - - async def _fetch_data(self): - """Fetch multimatic system.""" - - try: - system = await self._manager.get_system() - _LOGGER.debug("fetch_data successful") - return system - except ApiError as err: - await self._log_error(err) - if err.status < 500: - await self._manager.logout() - await self.authenticate() - raise - - async def logout(self): - """Logout from API.""" - - try: - await self._manager.logout() - except ApiError: - _LOGGER.warning("Cannot logout from multimatic API", exc_info=True) - return False - return True - - @staticmethod - async def _log_error(api_err, exec_info=True): - if api_err.status == 409: - _LOGGER.warning( - "Multimatic API: %s, status: %s, response: %s", - api_err.message, - api_err.status, - api_err.response, - ) - else: - _LOGGER.error( - "Error with multimatic API: %s, status: %s, response: %s", - api_err.message, - api_err.status, - api_err.response, - exc_info=exec_info, - ) - - def find_component(self, comp): - """Find a component in the system with the given id, no IO is done.""" - - if isinstance(comp, Zone): - return self.get_zone(comp.id) - if isinstance(comp, Room): - return self.get_room(comp.id) - if isinstance(comp, HotWater): - if self.data.dhw.hotwater and self.data.dhw.hotwater.id == comp.id: - return self.data.dhw.hotwater - if isinstance(comp, Circulation): - if self.data.dhw.circulation and self.data.dhw.circulation.id == comp.id: - return self.data.dhw.circulation - - return None - - def get_room(self, room_id): - """Get room by id.""" - return next((room for room in self.data.rooms if room.id == room_id), None) - - def get_room_device(self, sgtin): - """Get device of a room.""" - for room in self.data.rooms: - for device in room.devices: - if device.sgtin == sgtin: - return device - - def get_report(self, report_id): - """Get report id.""" - return next( - (report for report in self.data.reports if report.id == report_id), None + 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 ) - def get_zone(self, zone_id): - """Get zone by id.""" - return next((zone for zone in self.data.zones if zone.id == zone_id), None) - async def set_hot_water_target_temperature(self, entity, target_temp): """Set hot water target temperature. @@ -192,18 +178,17 @@ async def set_hot_water_target_temperature(self, entity, target_temp): """ hotwater = entity.component - touch_system = await self._remove_quick_mode_or_holiday(entity) - - current_mode = self.data.get_active_mode_hot_water(hotwater).current + 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 - self.data.hot_water = hotwater await self._refresh(touch_system, entity) async def set_room_target_temperature(self, entity, target_temp): @@ -220,7 +205,7 @@ async def set_room_target_temperature(self, entity, target_temp): touch_system = await self._remove_quick_mode_or_holiday(entity) room = entity.component - current_mode = self.data.get_active_mode_room(room).current + current_mode = self.get_active_mode(room).current if current_mode == OperatingModes.MANUAL: await self._manager.set_room_setpoint_temperature(room.id, target_temp) @@ -232,7 +217,6 @@ async def set_room_target_temperature(self, entity, target_temp): qveto = QuickVeto(DEFAULT_QUICK_VETO_DURATION, target_temp) await self._manager.set_room_quick_veto(room.id, qveto) room.quick_veto = qveto - self.data.set_room(room.id, room) await self._refresh(touch_system, entity) @@ -250,7 +234,8 @@ async def set_zone_target_temperature(self, entity, target_temp): touch_system = await self._remove_quick_mode_or_holiday(entity) zone = entity.component - current_mode = self.data.get_active_mode_zone(zone).current + + current_mode = self.get_active_mode(zone).current if current_mode == OperatingModes.QUICK_VETO: await self._manager.remove_zone_quick_veto(zone.id) @@ -259,7 +244,6 @@ async def set_zone_target_temperature(self, entity, target_temp): await self._manager.set_zone_quick_veto(zone.id, veto) zone.quick_veto = veto - self.data.set_zone(zone.id, zone) await self._refresh(touch_system, entity) async def set_hot_water_operating_mode(self, entity, mode): @@ -274,7 +258,6 @@ async def set_hot_water_operating_mode(self, entity, mode): await self._manager.set_hot_water_operating_mode(hotwater.id, mode) hotwater.operating_mode = mode - self.data.dhw.hotwater = hotwater await self._refresh(touch_system, entity) async def set_room_operating_mode(self, entity, mode): @@ -291,13 +274,12 @@ async def set_room_operating_mode(self, entity, mode): if isinstance(mode, QuickMode): await self._manager.set_quick_mode(mode) - self.data.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 - self.data.set_room(room.id, room) await self._refresh(touch_system, entity) async def set_zone_operating_mode(self, entity, mode): @@ -315,7 +297,7 @@ async def set_zone_operating_mode(self, entity, mode): if isinstance(mode, QuickMode): await self._manager.set_quick_mode(mode) - self.data.quick_mode = mode + self._quick_mode = mode touch_system = True else: if zone.heating and mode in ZoneHeating.MODES: @@ -325,7 +307,6 @@ async def set_zone_operating_mode(self, entity, mode): await self._manager.set_zone_cooling_operating_mode(zone.id, mode) zone.cooling.operating_mode = mode - self.data.set_zone(zone.id, zone) await self._refresh(touch_system, entity) async def remove_quick_mode(self, entity=None): @@ -345,23 +326,20 @@ async def remove_holiday_mode(self): 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.data.holiday = HolidayMode(True, 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): """Set quick mode (remove previous one).""" - try: - await self._remove_quick_mode_no_refresh() - qmode = QuickModes.get(mode) - await self._manager.set_quick_mode(qmode) - self.data.quick_mode = qmode - await self._refresh_entities() - except ApiError as err: - await self._log_error(err) + await self._remove_quick_mode_no_refresh() + qmode = QuickModes.get(mode) + await self._manager.set_quick_mode(qmode) + self._quick_mode = qmode + await self._refresh_entities() async def set_quick_veto(self, entity, temperature, duration=None): """Set quick veto for the given entity.""" - comp = self.find_component(entity.component) + comp = entity.component q_duration = duration if duration else DEFAULT_QUICK_VETO_DURATION qveto = QuickVeto(q_duration, temperature) @@ -379,7 +357,7 @@ async def set_quick_veto(self, entity, temperature, duration=None): async def remove_quick_veto(self, entity): """Remove quick veto for the given entity.""" - comp = self.find_component(entity.component) + comp = entity.component if comp and comp.quick_veto: if isinstance(comp, Zone): @@ -396,24 +374,24 @@ async def set_fan_operating_mode(self, entity, mode: Mode): if isinstance(mode, QuickMode): await self._manager.set_quick_mode(mode) - self.data.quick_mode = mode + self._quick_mode = mode touch_system = True else: await self._manager.set_ventilation_operating_mode( - self.data.ventilation.id, mode + entity.component.id, mode ) - self.data.ventilation.operating_mode = mode + entity.component.operating_mode = mode await self._refresh(touch_system, entity) async def _remove_quick_mode_no_refresh(self, entity=None): removed = False - qmode = self.data.quick_mode + qmode = self._quick_mode if entity and qmode: if qmode.is_for(entity.component): await self._hard_remove_quick_mode() removed = True - else: + else: # coming from service call await self._hard_remove_quick_mode() removed = True @@ -421,16 +399,12 @@ async def _remove_quick_mode_no_refresh(self, entity=None): async def _hard_remove_quick_mode(self): await self._manager.remove_quick_mode() - self.data.quick_mode = None + self._quick_mode = None async def _remove_holiday_mode_no_refresh(self): - removed = False - - if self.data.holiday is not None and self.data.holiday.is_applied: - removed = True - await self._manager.remove_holiday_mode() - self.data.holiday = HolidayMode(False) - return removed + await self._manager.remove_holiday_mode() + self._holiday_mode = HolidayMode(False) + return True async def _remove_quick_mode_or_holiday(self, entity): return ( @@ -440,10 +414,99 @@ async def _remove_quick_mode_or_holiday(self, entity): async def _refresh_entities(self): """Fetch multimatic data and force refresh of all listening entities.""" - self.hass.bus.async_fire(REFRESH_ENTITIES_EVENT, {}) + 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() - if entity and not entity.listening: - entity.async_schedule_update_ha_state(True) + 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: + """Find component by it's 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: + 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): + result = await self._fetch_data() + self.update_method = self._fetch_data_if_needed + return result + + async def _safe_logout(self): + try: + await self.api.logout() + except ApiError: + self.logger.debug("Error during logout", exc_info=True) diff --git a/custom_components/multimatic/entities.py b/custom_components/multimatic/entities.py index b4f2c0a..794dedb 100644 --- a/custom_components/multimatic/entities.py +++ b/custom_components/multimatic/entities.py @@ -7,8 +7,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from . import MultimaticDataUpdateCoordinator -from .const import DOMAIN as MULTIMATIC, REFRESH_ENTITIES_EVENT +from .const import DOMAIN as MULTIMATIC +from .coordinator import MultimaticCoordinator _LOGGER = logging.getLogger(__name__) @@ -16,62 +16,36 @@ class MultimaticEntity(CoordinatorEntity, ABC): """Define base class for multimatic entities.""" - coordinator: MultimaticDataUpdateCoordinator + coordinator: MultimaticCoordinator - def __init__(self, coordinator: MultimaticDataUpdateCoordinator, domain, device_id): + def __init__(self, coordinator: MultimaticCoordinator, domain, device_id): """Initialize entity.""" super().__init__(coordinator) id_part = slugify( device_id - + ( - f"_{coordinator.data.info.serial_number}" - if coordinator.fixed_serial - else "" - ) + + (f"_{coordinator.api.serial}" if coordinator.api.fixed_serial else "") ) self.entity_id = f"{domain}.{id_part}" - self._unique_id = slugify( - f"{MULTIMATIC}_{coordinator.data.info.serial_number}_{device_id}" - ) + self._unique_id = slugify(f"{MULTIMATIC}_{coordinator.api.serial}_{device_id}") self._remove_listener = None @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return a unique ID.""" return self._unique_id - @property - def listening(self): - """Return whether this entity is listening for system changes or not. - - System changes are quick mode or holiday mode. - """ - return False - 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) - if self.listening: - - def handle_event(event): - _LOGGER.debug("%s received event", self.entity_id) - self.async_schedule_update_ha_state(True) - - _LOGGER.debug( - "%s Will listen to %s", self.entity_id, REFRESH_ENTITIES_EVENT - ) - self._remove_listener = self.hass.bus.async_listen( - REFRESH_ENTITIES_EVENT, handle_event - ) + 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() - if self._remove_listener: - self._remove_listener() + self.coordinator.remove_api_listener(self.unique_id) @property def available(self) -> bool: diff --git a/custom_components/multimatic/fan.py b/custom_components/multimatic/fan.py index 4eef53e..fbef1e3 100644 --- a/custom_components/multimatic/fan.py +++ b/custom_components/multimatic/fan.py @@ -9,9 +9,10 @@ from homeassistant.components.fan import DOMAIN, SUPPORT_PRESET_MODE, FanEntity -from . import MultimaticDataUpdateCoordinator -from .const import COORDINATOR, DOMAIN as MULTIMATIC +from .const import VENTILATION +from .coordinator import MultimaticCoordinator from .entities import MultimaticEntity +from .utils import get_coordinator _LOGGER = logging.getLogger(__name__) @@ -19,11 +20,9 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the multimatic fan platform.""" - coordinator: MultimaticDataUpdateCoordinator = hass.data[MULTIMATIC][ - entry.unique_id - ][COORDINATOR] + coordinator = get_coordinator(hass, VENTILATION, entry.unique_id) - if coordinator.data.ventilation: + if coordinator.data: _LOGGER.debug("Adding fan entity") async_add_entities([MultimaticFan(coordinator)]) @@ -31,13 +30,13 @@ async def async_setup_entry(hass, entry, async_add_entities): class MultimaticFan(MultimaticEntity, FanEntity): """Representation of a multimatic fan.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + def __init__(self, coordinator: MultimaticCoordinator) -> None: """Initialize entity.""" super().__init__( coordinator, DOMAIN, - coordinator.data.ventilation.id, + coordinator.data.id, ) self._preset_modes = [ @@ -49,20 +48,16 @@ def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: @property def component(self): """Return the ventilation.""" - return self.coordinator.data.ventilation + return self.coordinator.data @property def name(self) -> str: """Return the name of the entity.""" - return ( - self.coordinator.data.ventilation.name - if self.coordinator.data.ventilation - else None - ) + 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.set_fan_operating_mode( + return await self.coordinator.api.set_fan_operating_mode( self, OperatingModes.get(preset_mode.upper()) ) @@ -80,19 +75,18 @@ async def async_turn_on( mode = OperatingModes.get(speed.upper()) else: mode = OperatingModes.AUTO - return await self.coordinator.set_fan_operating_mode(self, mode) + 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.set_fan_operating_mode(self, OperatingModes.NIGHT) + 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.coordinator.data.get_active_mode_ventilation().current - != OperatingModes.NIGHT - ) + return self.active_mode.current != OperatingModes.NIGHT @property def supported_features(self) -> int: @@ -102,7 +96,7 @@ def supported_features(self) -> int: @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., auto, smart, interval, favorite.""" - return self.coordinator.data.get_active_mode_ventilation().current.name + return self.active_mode.current.name @property def preset_modes(self) -> list[str] | None: @@ -110,14 +104,16 @@ def preset_modes(self) -> list[str] | None: Requires SUPPORT_SET_SPEED. """ - if ( - self.coordinator.data.get_active_mode_ventilation().current - == QuickModes.VENTILATION_BOOST - ): + 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 is not None + return super().available and self.component + + @property + def active_mode(self): + """Return the active mode.""" + return self.coordinator.api.get_active_mode(self.component) diff --git a/custom_components/multimatic/hub.py b/custom_components/multimatic/hub.py deleted file mode 100644 index b9bf497..0000000 --- a/custom_components/multimatic/hub.py +++ /dev/null @@ -1,418 +0,0 @@ -"""Api hub and integration data.""" -import logging - -from pymultimatic.api import ApiError -from pymultimatic.model import ( - Circulation, - HolidayMode, - HotWater, - Mode, - OperatingModes, - QuickMode, - QuickModes, - QuickVeto, - Room, - System, - Zone, - ZoneCooling, - ZoneHeating, -) -import pymultimatic.systemmanager - -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, - DEFAULT_SMART_PHONE_ID, - DOMAIN, - REFRESH_ENTITIES_EVENT, -) -from .utils import get_scan_interval - -_LOGGER = logging.getLogger(__name__) - - -async def check_authentication(hass, username, password, serial): - """Check if provided username an password are corrects.""" - return await pymultimatic.systemmanager.SystemManager( - username, - password, - async_create_clientsession(hass), - DEFAULT_SMART_PHONE_ID, - serial, - ).login(True) - - -class ApiHub(DataUpdateCoordinator[System]): - """multimatic entry point for home-assistant.""" - - def __init__(self, hass, entry: ConfigEntry): - """Initialize hub.""" - - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - serial = entry.data.get(CONF_SERIAL_NUMBER) - # app = entry.data.get(CONF_APPLICATION) - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=get_scan_interval(entry), - update_method=self._fetch_data, - ) - - session = async_create_clientsession(hass) - self._manager = pymultimatic.systemmanager.SystemManager( - username, password, session, DEFAULT_SMART_PHONE_ID, serial - ) - - self.serial: str = serial - - async def authenticate(self): - """Try to authenticate to the API.""" - try: - return await self._manager.login(True) - except ApiError as err: - await self._handle_api_error(err) - return False - - 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.response.status == 409: - _LOGGER.warning("Request_hvac_update is done too often") - else: - await self._handle_api_error(err) - await self.authenticate() - - async def _fetch_data(self): - """Fetch multimatic system.""" - - try: - system = await self._manager.get_system() - _LOGGER.debug("fetch_data successful") - return system - except ApiError as err: - auth_ok = False - try: - auth_ok = await self.authenticate() - finally: - await self._handle_api_error(err, auth_ok) - - async def logout(self): - """Logout from API.""" - - try: - await self._manager.logout() - except ApiError: - _LOGGER.warning("Cannot logout from multimatic API", exc_info=True) - return False - return True - - async def _handle_api_error(self, api_err, debug=False): - resp = await api_err.response.text() - _LOGGER.log( - logging.DEBUG if debug else logging.ERROR, - "Unable to fetch data from multimatic, API says: %s, status: %s", - resp, - api_err.response.status, - ) - - def find_component(self, comp): - """Find a component in the system with the given id, no IO is done.""" - - if isinstance(comp, Zone): - for zone in self.data.zones: - if zone.id == comp.id: - return zone - if isinstance(comp, Room): - for room in self.data.rooms: - if room.id == comp.id: - return room - if isinstance(comp, HotWater): - if self.data.dhw.hotwater and self.data.dhw.hotwater.id == comp.id: - return self.data.dhw.hotwater - if isinstance(comp, Circulation): - if self.data.dhw.circulation and self.data.dhw.circulation.id == comp.id: - return self.data.dhw.circulation - - return None - - 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.data.get_active_mode_hot_water(hotwater).current - - if current_mode == OperatingModes.OFF or touch_system: - await self._manager.set_hot_water_operating_mode( - hotwater.id, OperatingModes.ON - ) - await self._manager.set_hot_water_setpoint_temperature(hotwater.id, target_temp) - - self.data.hot_water = hotwater - 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.data.get_active_mode_room(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 - self.data.set_room(room.id, room) - - 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.data.get_active_mode_zone(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 - - self.data.set_zone(zone.id, zone) - 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 - - self.data.dhw.hotwater = hotwater - 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._manager.set_quick_mode(mode) - self.data.quick_mode = mode - touch_system = True - else: - await self._manager.set_room_operating_mode(room.id, mode) - room.operating_mode = mode - - self.data.set_room(room.id, room) - 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._manager.set_quick_mode(mode) - self.data.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 - - self.data.set_zone(zone.id, zone) - 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.data.holiday = HolidayMode(True, start_date, end_date, temperature) - await self._refresh_entities() - - async def set_quick_mode(self, mode): - """Set quick mode (remove previous one).""" - await self._remove_quick_mode_no_refresh() - qmode = QuickModes.get(mode) - await self._manager.set_quick_mode(qmode) - self.data.quick_mode = qmode - await self._refresh_entities() - - async def set_quick_veto(self, entity, temperature, duration=None): - """Set quick veto for the given entity.""" - comp = self.find_component(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 = self.find_component(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._manager.set_quick_mode(mode) - self.data.quick_mode = mode - touch_system = True - else: - await self._manager.set_ventilation_operating_mode( - self.data.ventilation.id, mode - ) - self.data.ventilation.operating_mode = mode - await self._refresh(touch_system, entity) - - async def _remove_quick_mode_no_refresh(self, entity=None): - removed = False - - if self.data.quick_mode is not None: - qmode = self.data.quick_mode - - if entity: - if qmode.is_for(entity.component): - await self._hard_remove_quick_mode() - removed = True - else: - await self._hard_remove_quick_mode() - removed = True - - return removed - - async def _hard_remove_quick_mode(self): - await self._manager.remove_quick_mode() - self.data.quick_mode = None - - async def _remove_holiday_mode_no_refresh(self): - removed = False - - if self.data.holiday is not None and self.data.holiday.is_applied: - removed = True - await self._manager.remove_holiday_mode() - self.data.holiday = HolidayMode(False) - return removed - - 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.""" - self.hass.bus.async_fire(REFRESH_ENTITIES_EVENT, {}) - - async def _refresh(self, touch_system, entity): - if touch_system: - await self._refresh_entities() - else: - entity.async_schedule_update_ha_state(True) diff --git a/custom_components/multimatic/manifest.json b/custom_components/multimatic/manifest.json index 4d3b4fa..b9efc90 100644 --- a/custom_components/multimatic/manifest.json +++ b/custom_components/multimatic/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://github.com/thomasgermain/vaillant-component", "issue_tracker": "https://github.com/thomasgermain/vaillant-component/issues", "requirements": [ - "pymultimatic==0.4.2" + "pymultimatic==0.5.0b0" ], "ssdp": [], "zeroconf": [], @@ -14,6 +14,6 @@ "codeowners": [ "@thomasgermain" ], - "version": "1.7.0b1", + "version": "1.7.0b2", "iot_class": "cloud_polling" } diff --git a/custom_components/multimatic/sensor.py b/custom_components/multimatic/sensor.py index ebef693..d22e17b 100644 --- a/custom_components/multimatic/sensor.py +++ b/custom_components/multimatic/sensor.py @@ -13,9 +13,10 @@ ) from homeassistant.const import TEMP_CELSIUS -from . import MultimaticDataUpdateCoordinator -from .const import COORDINATOR, DOMAIN as MULTIMATIC +from .const import OUTDOOR_TEMP, REPORTS +from .coordinator import MultimaticCoordinator from .entities import MultimaticEntity +from .utils import get_coordinator _LOGGER = logging.getLogger(__name__) @@ -29,15 +30,14 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the multimatic sensors.""" sensors = [] - coordinator = hass.data[MULTIMATIC][entry.unique_id][COORDINATOR] + outdoor_temp_coo = get_coordinator(hass, OUTDOOR_TEMP, entry.unique_id) + reports_coo = get_coordinator(hass, REPORTS, entry.unique_id) - if coordinator.data: - if coordinator.data.outdoor_temperature: - sensors.append(OutdoorTemperatureSensor(coordinator)) + if outdoor_temp_coo.data: + sensors.append(OutdoorTemperatureSensor(outdoor_temp_coo)) - sensors.extend( - ReportSensor(coordinator, report) for report in coordinator.data.reports - ) + if reports_coo.data: + sensors.extend(ReportSensor(reports_coo, report) for report in reports_coo.data) _LOGGER.info("Adding %s sensor entities", len(sensors)) @@ -48,21 +48,19 @@ async def async_setup_entry(hass, entry, async_add_entities): class OutdoorTemperatureSensor(MultimaticEntity): """Outdoor temperature sensor.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + def __init__(self, coordinator: MultimaticCoordinator) -> None: """Initialize entity.""" super().__init__(coordinator, DOMAIN, "outdoor_temperature") @property def state(self): """Return the state of the entity.""" - return self.coordinator.data.outdoor_temperature + return self.coordinator.data @property def available(self): """Return True if entity is available.""" - return ( - super().available and self.coordinator.data.outdoor_temperature is not None - ) + return super().available and self.coordinator.data is not None @property def unit_of_measurement(self): @@ -83,9 +81,7 @@ def device_class(self) -> str: class ReportSensor(MultimaticEntity): """Report sensor.""" - def __init__( - self, coordinator: MultimaticDataUpdateCoordinator, report: Report - ) -> None: + def __init__(self, coordinator: MultimaticCoordinator, report: Report) -> None: """Init entity.""" MultimaticEntity.__init__(self, coordinator, DOMAIN, report.id) self._report_id = report.id @@ -98,7 +94,14 @@ def __init__( @property def report(self): """Get the current report based on the id.""" - return self.coordinator.get_report(self._report_id) + return next( + ( + report + for report in self.coordinator.data + if report.id == self._report_id + ), + None, + ) @property def state(self): @@ -119,13 +122,7 @@ def unit_of_measurement(self) -> str | None: def device_info(self): """Return device specific attributes.""" return { - "identifiers": { - ( - DOMAIN, - self._device_id, - self.coordinator.data.info.serial_number, - ) - }, + "identifiers": {(DOMAIN, self._device_id)}, "name": self._device_name, "manufacturer": "Vaillant", } diff --git a/custom_components/multimatic/service.py b/custom_components/multimatic/service.py index 72a52c0..baf7c40 100644 --- a/custom_components/multimatic/service.py +++ b/custom_components/multimatic/service.py @@ -7,7 +7,6 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.util.dt import parse_date -from . import MultimaticDataUpdateCoordinator from .const import ( ATTR_DURATION, ATTR_END_DATE, @@ -15,6 +14,7 @@ ATTR_START_DATE, ATTR_TEMPERATURE, ) +from .coordinator import MultimaticApi _LOGGER = logging.getLogger(__name__) @@ -87,20 +87,20 @@ class MultimaticServiceHandler: """Service implementation.""" - def __init__(self, hub: MultimaticDataUpdateCoordinator, hass) -> None: + def __init__(self, hub: MultimaticApi, hass) -> None: """Init.""" - self._hub = hub + self.api = hub self._hass = hass async def service_call(self, call): - """Handle service 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._hub.remove_quick_mode() + await self.api.remove_quick_mode() async def set_holiday_mode(self, data): """Set holiday mode.""" @@ -111,17 +111,17 @@ async def set_holiday_mode(self, data): 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._hub.set_holiday_mode(start, end, temp) + await self.api.set_holiday_mode(start, end, temp) async def remove_holiday_mode(self, data): """Remove holiday mode.""" - await self._hub.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) - await self._hub.set_quick_mode(quick_mode) + await self.api.set_quick_mode(quick_mode) async def request_hvac_update(self, data): """Ask multimatic API to get data from the installation.""" - await self._hub.request_hvac_update() + await self.api.request_hvac_update() diff --git a/custom_components/multimatic/utils.py b/custom_components/multimatic/utils.py index 79ea114..c2e2427 100644 --- a/custom_components/multimatic/utils.py +++ b/custom_components/multimatic/utils.py @@ -1,46 +1,51 @@ -"""Utilities for HA.""" -from datetime import timedelta - -from pymultimatic.model import OperatingModes - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.util import dt - -from .const import ( - ATTR_ENDS_AT, - ATTR_MULTIMATIC_MODE, - ATTR_MULTIMATIC_SETTING, - DEFAULT_SCAN_INTERVAL, -) - - -def gen_state_attrs(component, active_mode): - """Generate state_attrs.""" - attrs = {} - attrs.update({ATTR_MULTIMATIC_MODE: active_mode.current.name}) - if active_mode.sub is not None: - attrs.update({ATTR_MULTIMATIC_SETTING: active_mode.sub.name}) - - if active_mode.current == OperatingModes.QUICK_VETO: - qveto_end = _get_quick_veto_end(component) - if qveto_end: - attrs.update({ATTR_ENDS_AT: qveto_end.isoformat()}) - return attrs - - -def get_scan_interval(entry: ConfigEntry): - """Get option scan interval or default.""" - return timedelta( - minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) - - -def _get_quick_veto_end(component): - end_time = None - # there is no remaining duration for zone - if component.quick_veto.duration: - millis = component.quick_veto.duration * 60 * 1000 - end_time = dt.now() + timedelta(milliseconds=millis) - end_time = end_time.replace(second=0, microsecond=0) - return end_time +"""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/custom_components/multimatic/water_heater.py b/custom_components/multimatic/water_heater.py index ac872d8..4e97501 100644 --- a/custom_components/multimatic/water_heater.py +++ b/custom_components/multimatic/water_heater.py @@ -12,9 +12,10 @@ ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from . import MultimaticDataUpdateCoordinator -from .const import COORDINATOR, DOMAIN as MULTIMATIC +from .const import DHW +from .coordinator import MultimaticCoordinator from .entities import MultimaticEntity +from .utils import get_coordinator _LOGGER = logging.getLogger(__name__) @@ -35,9 +36,9 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up water_heater platform.""" entities = [] - coordinator = hass.data[MULTIMATIC][entry.unique_id][COORDINATOR] + coordinator = get_coordinator(hass, DHW, entry.unique_id) - if coordinator.data and coordinator.data.dhw and coordinator.data.dhw.hotwater: + if coordinator.data and coordinator.data.hotwater: entities.append(MultimaticWaterHeater(coordinator)) async_add_entities(entities) @@ -47,31 +48,26 @@ async def async_setup_entry(hass, entry, async_add_entities): class MultimaticWaterHeater(MultimaticEntity, WaterHeaterEntity): """Represent the multimatic water heater.""" - def __init__(self, coordinator: MultimaticDataUpdateCoordinator) -> None: + def __init__(self, coordinator: MultimaticCoordinator) -> None: """Initialize entity.""" - super().__init__(coordinator, DOMAIN, coordinator.data.dhw.hotwater.id) + super().__init__(coordinator, DOMAIN, coordinator.data.hotwater.id) self._operations = {mode.name: mode for mode in HotWater.MODES} - self._name = coordinator.data.dhw.hotwater.name + self._name = coordinator.data.hotwater.name @property def name(self) -> str: """Return the name of the entity.""" return self._name - @property - def listening(self): - """Return whether this entity is listening for system changes or not.""" - return True - @property def component(self): """Return multimatic component.""" - return self.coordinator.data.dhw.hotwater + return self.coordinator.data.hotwater @property def active_mode(self): """Return multimatic component's active mode.""" - return self.coordinator.data.get_active_mode_hot_water() + return self.coordinator.api.get_active_mode(self.component) @property def supported_features(self): @@ -152,20 +148,24 @@ def is_away_mode_on(self): async def async_set_temperature(self, **kwargs): """Set new target temperature.""" target_temp = float(kwargs.get(ATTR_TEMPERATURE)) - await self.coordinator.set_hot_water_target_temperature(self, target_temp) + 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.set_hot_water_operating_mode(self, 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.set_hot_water_operating_mode(self, OperatingModes.OFF) + 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.set_hot_water_operating_mode(self, OperatingModes.AUTO) + await self.coordinator.api.set_hot_water_operating_mode( + self, OperatingModes.AUTO + )