From 1bd30af94c57fca3880022c44ffbe522dd3c2c88 Mon Sep 17 00:00:00 2001 From: thomasgermain <12560542+thomasgermain@users.noreply.github.com> Date: Fri, 14 May 2021 08:38:05 +0200 Subject: [PATCH] v1.7.0b0 --- README.md | 9 + custom_components/multimatic/__init__.py | 10 +- custom_components/multimatic/binary_sensor.py | 328 ++++++++----- custom_components/multimatic/climate.py | 97 ++-- custom_components/multimatic/config_flow.py | 17 +- custom_components/multimatic/const.py | 9 +- custom_components/multimatic/coordinator.py | 436 ++++++++++++++++++ custom_components/multimatic/entities.py | 56 +-- custom_components/multimatic/fan.py | 30 +- custom_components/multimatic/manifest.json | 4 +- custom_components/multimatic/sensor.py | 80 ++-- custom_components/multimatic/service.py | 4 +- custom_components/multimatic/strings.json | 1 + custom_components/multimatic/water_heater.py | 33 +- 14 files changed, 815 insertions(+), 299 deletions(-) create mode 100644 custom_components/multimatic/coordinator.py diff --git a/README.md b/README.md index 52be631..574908f 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,15 @@ You can do some find and replace (e.g. `climate.vaillant_bathroom`-> `climate.ba - Integration into the HACS default repositories - Special thanks to [@vit-](https://github.com/vit-) +### [1.7.0b](https://github.com/thomasgermain/vaillant-component/releases/tag/1.7.0b0) +- **BREAKING CHANGE**: improvements of the `unique_id` of some entities (but not the `entity_id`). + You may have to delete old entities which will be unavailable + and change the `entity_id` of newly created entities (because they may have `entity_id` like xxx_2) +- Names and units of sensors are None safe +- Many technical improvements on entity updates +- Better handling and logging in case of error +- Update pymultimatic to 0.4.x (= improvement in handling wrong responses coming from the API) + ## Provided entities - 1 water_heater entity, if any water heater: `water_heater.`, basically `water_heater.control_dhw` - 1 climate entity per zone (expect if the zone is controlled by room) `climate.` diff --git a/custom_components/multimatic/__init__.py b/custom_components/multimatic/__init__.py index 1d5d3e7..afdc967 100644 --- a/custom_components/multimatic/__init__.py +++ b/custom_components/multimatic/__init__.py @@ -6,8 +6,8 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from .const import DOMAIN, HUB, PLATFORMS, SERVICES_HANDLER -from .hub import ApiHub +from .const import COORDINATOR, DOMAIN, PLATFORMS, SERVICES_HANDLER +from .coordinator import MultimaticDataUpdateCoordinator from .service import SERVICES, MultimaticServiceHandler _LOGGER = logging.getLogger(__name__) @@ -21,13 +21,13 @@ 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: ApiHub = ApiHub(hass, entry) + api: MultimaticDataUpdateCoordinator = MultimaticDataUpdateCoordinator(hass, entry) await api.authenticate() await api.async_refresh() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(entry.unique_id, {}) - hass.data[DOMAIN][entry.unique_id][HUB] = api + hass.data[DOMAIN][entry.unique_id][COORDINATOR] = api for platform in PLATFORMS: hass.async_create_task( @@ -44,7 +44,7 @@ async def logout(param): return True -async def async_setup_service(api: ApiHub, hass): +async def async_setup_service(api: MultimaticDataUpdateCoordinator, 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 e300569..469ec87 100644 --- a/custom_components/multimatic/binary_sensor.py +++ b/custom_components/multimatic/binary_sensor.py @@ -17,8 +17,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.util import slugify -from . import ApiHub -from .const import DOMAIN as MULTIMATIC, HUB +from . import MultimaticDataUpdateCoordinator +from .const import COORDINATOR, DOMAIN as MULTIMATIC from .entities import MultimaticEntity _LOGGER = logging.getLogger(__name__) @@ -27,29 +27,35 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the multimatic binary sensor platform.""" sensors = [] - hub: ApiHub = hass.data[MULTIMATIC][entry.unique_id][HUB] - if hub.data: - if hub.data.dhw and hub.data.dhw.circulation: - sensors.append(CirculationSensor(hub)) - - if hub.data.boiler_status: - sensors.append(BoilerStatus(hub)) - - if hub.data.info: - sensors.append(BoxOnline(hub)) - sensors.append(BoxUpdate(hub)) - - for room in hub.data.rooms: - sensors.append(RoomWindow(hub, room)) + 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(hub, device, room)) + sensors.append(RoomDeviceChildLock(coordinator, device, room)) - sensors.append(RoomDeviceBattery(hub, device, room)) - sensors.append(RoomDeviceConnectivity(hub, device, room)) + sensors.append(RoomDeviceBattery(coordinator, device)) + sensors.append(RoomDeviceConnectivity(coordinator, device)) sensors.extend( - [HolidayModeSensor(hub), QuickModeSensor(hub), MultimaticErrors(hub)] + [ + HolidayModeSensor(coordinator), + QuickModeSensor(coordinator), + MultimaticErrors(coordinator), + ] ) _LOGGER.info("Adding %s binary sensor entities", len(sensors)) @@ -61,21 +67,18 @@ 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, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Initialize entity.""" - super().__init__( - hub, - DOMAIN, - "dhw circulation", - "Circulation", - DEVICE_CLASS_POWER, - False, - ) + super().__init__(coordinator, DOMAIN, "dhw_circulation") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_POWER @property def is_on(self): """Return true if the binary sensor is on.""" - return ( self.active_mode.current == OperatingModes.ON or self.active_mode.sub == SettingModes.ON @@ -85,7 +88,7 @@ def is_on(self): @property def available(self): """Return True if entity is available.""" - return super().available and self.component is not None + return super().available and self.circulation is not None @property def active_mode(self): @@ -93,7 +96,12 @@ def active_mode(self): return self.coordinator.data.get_active_mode_circulation() @property - def component(self): + def name(self) -> str: + """Return the name of the entity.""" + return self.circulation.name if self.circulation else None + + @property + def circulation(self): """Return the circulation.""" return self.coordinator.data.dhw.circulation @@ -101,63 +109,79 @@ def component(self): class RoomWindow(MultimaticEntity, BinarySensorEntity): """multimatic window binary sensor.""" - def __init__(self, hub: ApiHub, room: Room): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator, room: Room): """Initialize entity.""" - super().__init__(hub, DOMAIN, room.name, room.name, DEVICE_CLASS_WINDOW) - self._room = room + super().__init__(coordinator, DOMAIN, f"{room.name}_{DEVICE_CLASS_WINDOW}") + self._room_id = room.id @property def is_on(self): """Return true if the binary sensor is on.""" - return self.coordinator.find_component(self._room).window_open + return self.room.window_open @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 is not None + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_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): + """Return the room.""" + return self.coordinator.get_room(self._room_id) class RoomDeviceEntity(MultimaticEntity, BinarySensorEntity): """Base class for ambisense device.""" - def __init__(self, hub: ApiHub, device: Device, room: Room, device_class) -> None: + def __init__( + self, coordinator: MultimaticDataUpdateCoordinator, device: Device, extra_id + ) -> None: """Initialize device.""" MultimaticEntity.__init__( - self, hub, DOMAIN, device.sgtin, device.name, device_class + self, coordinator, DOMAIN, f"{device.sgtin}_{extra_id}" ) - self.room = room - self.device = device + self._sgtin = device.sgtin @property def device_info(self): """Return device specific attributes.""" + device = self.device return { - "identifiers": {(MULTIMATIC, self.device.sgtin)}, - "name": self.device.name, + "identifiers": {(MULTIMATIC, device.sgtin)}, + "name": device.name, "manufacturer": "Vaillant", - "model": self.device.device_type, + "model": device.device_type, } @property def device_state_attributes(self): """Return the state attributes.""" + device = self.device return { - "device_id": self.device.sgtin, - "battery_low": self.device.battery_low, - "connected": not self.device.radio_out_of_reach, + "device_id": device.sgtin, + "battery_low": device.battery_low, + "connected": not device.radio_out_of_reach, } - def find_device(self): - """Find a device in a room.""" - if self.room: - for device in self.coordinator.find_component(self.room).devices: - if device.sgtin == self.device.sgtin: - return device - @property def available(self): """Return True if entity is available.""" - return super().available and self.find_device() is not None + return super().available and self.device is not None + + @property + def device(self): + """Return the device.""" + return self.coordinator.get_room_device(self._sgtin) class RoomDeviceChildLock(RoomDeviceEntity): @@ -167,48 +191,76 @@ class RoomDeviceChildLock(RoomDeviceEntity): devices inside a room. """ - def __init__(self, hub: ApiHub, device: Device, room: Room): + def __init__( + self, coordinator: MultimaticDataUpdateCoordinator, device: Device, room: Room + ): """Initialize entity.""" - super().__init__(hub, device, room, DEVICE_CLASS_LOCK) + super().__init__(coordinator, device, DEVICE_CLASS_LOCK) + self._room_id = room.id @property def is_on(self): """According to the doc, true means unlock, false lock.""" - return not self.coordinator.find_component(self.room).child_lock + return not self.room.child_lock + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self.room is not None + + @property + def room(self): + """Return the room.""" + return self.coordinator.get_room(self._room_id) + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_LOCK class RoomDeviceBattery(RoomDeviceEntity): """Represent a device battery.""" - def __init__(self, hub: ApiHub, device: Device, room: Room): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator, device: Device): """Initialize entity.""" - super().__init__(hub, device, room, DEVICE_CLASS_BATTERY) + super().__init__(coordinator, device, DEVICE_CLASS_BATTERY) @property def is_on(self): """According to the doc, true means normal, false low.""" - return self.find_device().battery_low + return self.device.battery_low + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_BATTERY class RoomDeviceConnectivity(RoomDeviceEntity): """Device in room is out of reach or not.""" - def __init__(self, hub: ApiHub, device: Device, room: Room): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator, device: Device): """Initialize entity.""" - super().__init__(hub, device, room, DEVICE_CLASS_CONNECTIVITY) + super().__init__(coordinator, device, DEVICE_CLASS_CONNECTIVITY) @property def is_on(self): """According to the doc, true means connected, false disconnected.""" - return not self.find_device().radio_out_of_reach + return not self.device.radio_out_of_reach + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_CONNECTIVITY class VRBoxEntity(MultimaticEntity, BinarySensorEntity): """multimatic gateway device (ex: VR920).""" - def __init__(self, hub: ApiHub, device_class, name, comp_id): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator, comp_id): """Initialize entity.""" - MultimaticEntity.__init__(self, hub, DOMAIN, comp_id, name, device_class, False) + MultimaticEntity.__init__(self, coordinator, DOMAIN, comp_id) @property def device_info(self): @@ -245,13 +297,11 @@ def system_info(self): class BoxUpdate(VRBoxEntity): """Update binary sensor.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Init.""" super().__init__( - hub, - DEVICE_CLASS_POWER, - "Multimatic system update", - "multimatic_system_update", + coordinator, + "Multimatic_system_update", ) @property @@ -259,40 +309,47 @@ def is_on(self): """Return true if the binary sensor is on.""" return not self.coordinator.data.info.is_up_to_date + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_POWER + class BoxOnline(VRBoxEntity): """Check if box is online.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Init.""" - super().__init__( - hub, - DEVICE_CLASS_CONNECTIVITY, - "multimatic_system_online", - "Multimatic system Online", - ) + super().__init__(coordinator, "multimatic_system_online") @property def is_on(self): """Return true if the binary sensor is on.""" return self.coordinator.data.info.is_online + @property + def name(self): + """Return the name of the entity.""" + return "Multimatic system Online" + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_CONNECTIVITY + class BoilerStatus(MultimaticEntity, BinarySensorEntity): """Check if there is some error.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Initialize entity.""" MultimaticEntity.__init__( self, - hub, + coordinator, DOMAIN, - hub.data.boiler_status.device_name, - hub.data.boiler_status.device_name, - DEVICE_CLASS_PROBLEM, - False, + coordinator.data.boiler_status.device_name, ) - self._boiler_id = slugify(hub.data.boiler_status.device_name) + self._boiler_id = slugify(coordinator.data.boiler_status.device_name) @property def is_on(self): @@ -302,25 +359,23 @@ def is_on(self): @property def state_attributes(self): """Return the state attributes.""" - if self.boiler_status is not None: - return { - "status_code": self.boiler_status.status_code, - "title": self.boiler_status.title, - "timestamp": self.boiler_status.timestamp, - } - return None + 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.""" - if self.boiler_status is not None: - return { - "identifiers": {(MULTIMATIC, self._boiler_id, self.coordinator.serial)}, - "name": self.boiler_status.device_name, - "manufacturer": "Vaillant", - "model": self.boiler_status.device_name, - } - return None + return { + "identifiers": { + (MULTIMATIC, self._boiler_id, self.coordinator.data.info.serial_number) + }, + "name": self.boiler_status.device_name, + "manufacturer": "Vaillant", + "model": self.boiler_status.device_name, + } @property def device_state_attributes(self): @@ -334,24 +389,31 @@ def available(self) -> bool: """Return if entity is available.""" return super().available and self.boiler_status is not None + @property + def name(self): + """Return the name of the entity.""" + return self.boiler_status.device_name if self.boiler_status else None + @property def boiler_status(self): """Return the boiler status.""" return self.coordinator.data.boiler_status + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_PROBLEM + class MultimaticErrors(MultimaticEntity, BinarySensorEntity): """Check if there is any error message from system.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Init.""" super().__init__( - hub, + coordinator, DOMAIN, "multimatic_errors", - "Multimatic Errors", - DEVICE_CLASS_PROBLEM, - False, ) @property @@ -382,20 +444,23 @@ 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.""" + return DEVICE_CLASS_PROBLEM + + @property + def name(self): + """Return the name of the entity.""" + return "Multimatic Errors" + class HolidayModeSensor(MultimaticEntity, BinarySensorEntity): """Binary sensor for holiday mode.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Init.""" - super().__init__( - hub, - DOMAIN, - "multimatic_holiday", - "Multimatic holiday", - DEVICE_CLASS_POWER, - False, - ) + super().__init__(coordinator, DOMAIN, "multimatic_holiday") @property def is_on(self): @@ -413,27 +478,29 @@ def state_attributes(self): "end_date": self.coordinator.data.holiday.end_date.isoformat(), "temperature": self.coordinator.data.holiday.target, } - return {} @property def listening(self): """Return whether this entity is listening for system changes or not.""" return True + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_POWER + + @property + def name(self): + """Return the name of the entity.""" + return "Multimatic holiday" + class QuickModeSensor(MultimaticEntity, BinarySensorEntity): """Binary sensor for holiday mode.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Init.""" - super().__init__( - hub, - DOMAIN, - "multimatic_quick_mode", - "Multimatic quick mode", - DEVICE_CLASS_POWER, - False, - ) + super().__init__(coordinator, DOMAIN, "multimatic_quick_mode") @property def is_on(self): @@ -443,11 +510,20 @@ def is_on(self): @property def state_attributes(self): """Return the state attributes.""" - if self.is_on: + if self.coordinator.data.quick_mode: return {"quick_mode": self.coordinator.data.quick_mode.name} - return {} @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 quick mode" + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_POWER diff --git a/custom_components/multimatic/climate.py b/custom_components/multimatic/climate.py index 6371d09..2a1c85f 100644 --- a/custom_components/multimatic/climate.py +++ b/custom_components/multimatic/climate.py @@ -2,12 +2,13 @@ import abc import logging -from typing import Any, Dict, List, Optional +from typing import Dict, List, Optional from pymultimatic.model import ( ActiveFunction, ActiveMode, Component, + Mode, OperatingModes, QuickModes, Room, @@ -36,11 +37,11 @@ from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers import entity_platform -from . import SERVICES, ApiHub +from . import SERVICES, MultimaticDataUpdateCoordinator from .const import ( + COORDINATOR, DEFAULT_QUICK_VETO_DURATION, DOMAIN as MULTIMATIC, - HUB, PRESET_COOLING_FOR_X_DAYS, PRESET_COOLING_ON, PRESET_DAY, @@ -52,7 +53,6 @@ ) from .entities import MultimaticEntity from .service import SERVICE_REMOVE_QUICK_VETO, SERVICE_SET_QUICK_VETO -from .utils import gen_state_attrs _LOGGER = logging.getLogger(__name__) @@ -66,36 +66,37 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the multimatic climate platform.""" climates = [] - hub = hass.data[MULTIMATIC][entry.unique_id][HUB] + coordinator = hass.data[MULTIMATIC][entry.unique_id][COORDINATOR] - if hub.data: - if hub.data.zones: - for zone in hub.data.zones: + if coordinator.data: + if coordinator.data.zones: + for zone in coordinator.data.zones: if not zone.rbr and zone.enabled: - entity = ZoneClimate(hub, zone) + entity = ZoneClimate(coordinator, zone) climates.append(entity) - if hub.data.rooms: - rbr_zone = [zone for zone in hub.data.zones if zone.rbr][0] - for room in hub.data.rooms: - entity = RoomClimate(hub, room, rbr_zone) + 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) _LOGGER.info("Adding %s climate entities", len(climates)) async_add_entities(climates) - 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, - ) + 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 @@ -103,9 +104,14 @@ async def async_setup_entry(hass, entry, async_add_entities): class MultimaticClimate(MultimaticEntity, ClimateEntity, abc.ABC): """Base class for climate.""" - def __init__(self, hub: ApiHub, comp_name, comp_id, component: Component): + def __init__( + self, + coordinator: MultimaticDataUpdateCoordinator, + comp_id, + component: Component, + ): """Initialize entity.""" - super().__init__(hub, DOMAIN, comp_id, comp_name) + super().__init__(coordinator, DOMAIN, comp_id) self._component = component async def set_quick_veto(self, **kwargs): @@ -153,6 +159,11 @@ def current_temperature(self): """Return the current temperature.""" return self.component.temperature + @property + def name(self) -> str: + """Return the name of the entity.""" + return self.component.name if self.component else None + @property def is_aux_heat(self) -> Optional[bool]: """Return true if aux heater.""" @@ -207,7 +218,7 @@ def target_temperature_low(self) -> Optional[float]: class RoomClimate(MultimaticClimate): """Climate for a room.""" - _MULTIMATIC_TO_HA = { + _MULTIMATIC_TO_HA: Dict[Mode, list] = { OperatingModes.AUTO: [HVAC_MODE_AUTO, PRESET_COMFORT], OperatingModes.OFF: [HVAC_MODE_OFF, PRESET_NONE], OperatingModes.QUICK_VETO: [None, PRESET_QUICK_VETO], @@ -227,10 +238,12 @@ class RoomClimate(MultimaticClimate): PRESET_SYSTEM_OFF: QuickModes.SYSTEM_OFF, } - def __init__(self, hub: ApiHub, room: Room, zone: Zone): + def __init__( + self, coordinator: MultimaticDataUpdateCoordinator, room: Room, zone: Zone + ): """Initialize entity.""" - super().__init__(hub, room.name, room.name, room) - self._zone = zone + super().__init__(coordinator, room.name, room) + self._zone_id = zone.id self._supported_hvac = list(RoomClimate._HA_MODE_TO_MULTIMATIC.keys()) self._supported_presets = list(RoomClimate._HA_PRESET_TO_MULTIMATIC.keys()) @@ -275,7 +288,7 @@ def active_mode(self) -> ActiveMode: @property def zone(self): """Return the zone the current room belongs.""" - return self.coordinator.find_component(self._zone) + return self.coordinator.get_zone(self._zone_id) async def async_set_temperature(self, **kwargs): """Set new target temperature.""" @@ -288,13 +301,6 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: mode = RoomClimate._HA_MODE_TO_MULTIMATIC[hvac_mode] await self.coordinator.set_room_operating_mode(self, mode) - @property - def state_attributes(self) -> Dict[str, Any]: - """Return the optional state attributes.""" - attributes = super().state_attributes - attributes.update(gen_state_attrs(self.component, self.active_mode)) - return attributes - @property def preset_mode(self) -> Optional[str]: """Return the current preset mode, e.g., home, away, temp. @@ -335,7 +341,7 @@ def hvac_action(self) -> Optional[str]: class ZoneClimate(MultimaticClimate): """Climate for a zone.""" - _MULTIMATIC_TO_HA = { + _MULTIMATIC_TO_HA: Dict[Mode, list] = { OperatingModes.AUTO: [HVAC_MODE_AUTO, PRESET_COMFORT], OperatingModes.DAY: [None, PRESET_DAY], OperatingModes.NIGHT: [None, PRESET_SLEEP], @@ -369,9 +375,9 @@ class ZoneClimate(MultimaticClimate): PRESET_COOLING_FOR_X_DAYS: QuickModes.COOLING_FOR_X_DAYS, } - def __init__(self, hub: ApiHub, zone: Zone): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator, zone: Zone): """Initialize entity.""" - super().__init__(hub, zone.name, zone.id, zone) + super().__init__(coordinator, zone.id, zone) self._supported_hvac = list(ZoneClimate._HA_MODE_TO_MULTIMATIC.keys()) self._supported_presets = list(ZoneClimate._HA_PRESET_TO_MULTIMATIC.keys()) @@ -380,7 +386,7 @@ def __init__(self, hub: ApiHub, zone: Zone): self._supported_presets.remove(PRESET_COOLING_ON) self._supported_presets.remove(PRESET_COOLING_FOR_X_DAYS) - if not hub.data.ventilation: + if not coordinator.data.ventilation: self._supported_hvac.remove(HVAC_MODE_FAN_ONLY) @property @@ -452,13 +458,6 @@ async def async_set_hvac_mode(self, hvac_mode): mode = ZoneClimate._HA_MODE_TO_MULTIMATIC[hvac_mode] await self.coordinator.set_zone_operating_mode(self, mode) - @property - def state_attributes(self) -> Dict[str, Any]: - """Return the optional state attributes.""" - attributes = super().state_attributes - attributes.update(gen_state_attrs(self.component, self.active_mode)) - return attributes - @property def hvac_action(self) -> Optional[str]: """Return the current running hvac operation if supported. diff --git a/custom_components/multimatic/config_flow.py b/custom_components/multimatic/config_flow.py index 1723afb..9e0ff7d 100644 --- a/custom_components/multimatic/config_flow.py +++ b/custom_components/multimatic/config_flow.py @@ -14,7 +14,7 @@ DEFAULT_SCAN_INTERVAL, DOMAIN, ) -from .hub import check_authentication +from .coordinator import check_authentication _LOGGER = logging.getLogger(__name__) @@ -22,7 +22,6 @@ { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - # vol.Required(CONF_APPLICATION, default=MULTIMATIC): vol.In([MULTIMATIC, SENSO]), vol.Optional(CONF_SERIAL_NUMBER): str, } ) @@ -35,11 +34,7 @@ async def validate_input(hass: core.HomeAssistant, data): """ await validate_authentication( - hass, - data[CONF_USERNAME], - data[CONF_PASSWORD], - data.get(CONF_SERIAL_NUMBER), - # data.get(CONF_APPLICATION), + hass, data[CONF_USERNAME], data[CONF_PASSWORD], data.get(CONF_SERIAL_NUMBER) ) return {"title": "Multimatic"} @@ -51,11 +46,11 @@ async def validate_authentication(hass, username, password, serial): if not await check_authentication(hass, username, password, serial): raise InvalidAuth except ApiError as err: - resp = await err.response.text() _LOGGER.exception( - "Unable to authenticate, API says: %s, status: %s", - resp, - err.response.status, + "Unable to authenticate %s, status: %s, response: %s", + err.message, + err.status, + err.response, ) raise InvalidAuth from err diff --git a/custom_components/multimatic/const.py b/custom_components/multimatic/const.py index c7dd9ac..cf6973f 100644 --- a/custom_components/multimatic/const.py +++ b/custom_components/multimatic/const.py @@ -2,7 +2,6 @@ # constants used in hass.data DOMAIN = "multimatic" -HUB = "hub" ENTITIES = "entities" # list of platforms into entity are created @@ -33,12 +32,10 @@ CONF_QUICK_VETO_DURATION = "quick_veto_duration" CONF_SMARTPHONE_ID = "smartphoneid" CONF_SERIAL_NUMBER = "serial_number" -CONF_APPLICATION = "application" # constants for states_attributes -ATTR_MULTIMATIC_MODE = "multimatic_mode" -ATTR_MULTIMATIC_SETTING = "setting" -ATTR_ENDS_AT = "ends_at" +# ATTR_MULTIMATIC_SETTING = "setting" +# ATTR_ENDS_AT = "ends_at" ATTR_QUICK_MODE = "quick_mode" ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" @@ -46,6 +43,6 @@ ATTR_DURATION = "duration" SERVICES_HANDLER = "services_handler" -HUB = "hub" +COORDINATOR = "coordinator" REFRESH_ENTITIES_EVENT = "multimatic_refresh_entities" diff --git a/custom_components/multimatic/coordinator.py b/custom_components/multimatic/coordinator.py new file mode 100644 index 0000000..2e2a715 --- /dev/null +++ b/custom_components/multimatic/coordinator.py @@ -0,0 +1,436 @@ +"""Api hub and integration data.""" +from datetime import timedelta +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_SCAN_INTERVAL, 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_SCAN_INTERVAL, + DEFAULT_SMART_PHONE_ID, + DOMAIN, + REFRESH_ENTITIES_EVENT, +) + +_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 MultimaticDataUpdateCoordinator(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) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta( + minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ), + update_method=self._fetch_data, + ) + + session = async_create_clientsession(hass) + self._manager = pymultimatic.systemmanager.SystemManager( + username, password, session, DEFAULT_SMART_PHONE_ID, serial + ) + + self.fixed_serial = serial is not None + + 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 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 == 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) + 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): + _LOGGER.error( + "Error with multimatic API: %s, status: %s, response: %s", + api_err.message, + api_err.status, + api_err.response, + ) + + 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 + ) + + 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. + + * 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: + 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).""" + 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) + + 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 + + qmode = self.data.quick_mode + if entity and qmode: + 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() + if entity and not entity.listening: + entity.async_schedule_update_ha_state(True) diff --git a/custom_components/multimatic/entities.py b/custom_components/multimatic/entities.py index 490c8f0..34d8560 100644 --- a/custom_components/multimatic/entities.py +++ b/custom_components/multimatic/entities.py @@ -6,7 +6,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from . import ApiHub +from . import MultimaticDataUpdateCoordinator from .const import DOMAIN as MULTIMATIC, REFRESH_ENTITIES_EVENT _LOGGER = logging.getLogger(__name__) @@ -15,49 +15,32 @@ class MultimaticEntity(CoordinatorEntity, ABC): """Define base class for multimatic entities.""" - coordinator: ApiHub + coordinator: MultimaticDataUpdateCoordinator - def __init__( - self, hub: ApiHub, domain, device_id, name, dev_class=None, class_id=True - ): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator, domain, device_id): """Initialize entity.""" - super().__init__(hub) - self._device_class = dev_class - - temp_id = domain + "." - self._unique_id = domain + "." + MULTIMATIC + "_" - if hub.serial: - temp_id += hub.serial + "_" - self._unique_id += hub.serial + "_" - - temp_id += slugify(device_id) - self._unique_id += slugify(device_id) - - if dev_class and class_id: - temp_id += "_" + dev_class - self._unique_id += "_" + dev_class - - self.entity_id = temp_id.lower() - self._unique_id.lower() + super().__init__(coordinator) + + id_part = slugify( + device_id + + ( + f"_{coordinator.data.info.serial_number}" + if coordinator.fixed_serial + else "" + ) + ) - self._entity_name = name + self.entity_id = f"{domain}.{id_part}" + self._unique_id = slugify( + f"{MULTIMATIC}_{coordinator.data.info.serial_number}_{device_id}" + ) self._remove_listener = None - @property - def name(self) -> Optional[str]: - """Return the name of the entity.""" - return self._entity_name - @property def unique_id(self) -> Optional[str]: """Return a unique ID.""" return self._unique_id - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class - @property def listening(self): """Return whether this entity is listening for system changes or not. @@ -88,3 +71,8 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() if self._remove_listener: self._remove_listener() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data diff --git a/custom_components/multimatic/fan.py b/custom_components/multimatic/fan.py index 8130e53..657aa4f 100644 --- a/custom_components/multimatic/fan.py +++ b/custom_components/multimatic/fan.py @@ -7,8 +7,8 @@ from homeassistant.components.fan import DOMAIN, SUPPORT_PRESET_MODE, FanEntity -from . import ApiHub -from .const import DOMAIN as MULTIMATIC, HUB +from . import MultimaticDataUpdateCoordinator +from .const import COORDINATOR, DOMAIN as MULTIMATIC from .entities import MultimaticEntity _LOGGER = logging.getLogger(__name__) @@ -17,26 +17,25 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the multimatic fan platform.""" - hub: ApiHub = hass.data[MULTIMATIC][entry.unique_id][HUB] + coordinator: MultimaticDataUpdateCoordinator = hass.data[MULTIMATIC][ + entry.unique_id + ][COORDINATOR] - if hub.data.ventilation: + if coordinator.data.ventilation: _LOGGER.debug("Adding fan entity") - async_add_entities([MultimaticFan(hub)]) + async_add_entities([MultimaticFan(coordinator)]) class MultimaticFan(MultimaticEntity, FanEntity): """Representation of a multimatic fan.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Initialize entity.""" super().__init__( - hub, + coordinator, DOMAIN, - hub.data.ventilation.id, - hub.data.ventilation.name, - None, - False, + coordinator.data.ventilation.id, ) self._preset_modes = [ @@ -50,6 +49,15 @@ def component(self): """Return the ventilation.""" return self.coordinator.data.ventilation + @property + def name(self) -> str: + """Return the name of the entity.""" + return ( + self.coordinator.data.ventilation.name + if self.coordinator.data.ventilation + 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( diff --git a/custom_components/multimatic/manifest.json b/custom_components/multimatic/manifest.json index 363bfba..498156a 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.3.2" + "pymultimatic==0.4.1" ], "ssdp": [], "zeroconf": [], @@ -14,6 +14,6 @@ "codeowners": [ "@thomasgermain" ], - "version": "1.6.2", + "version": "1.7.0b0", "iot_class": "cloud_polling" } diff --git a/custom_components/multimatic/sensor.py b/custom_components/multimatic/sensor.py index 77d882d..e3d996d 100644 --- a/custom_components/multimatic/sensor.py +++ b/custom_components/multimatic/sensor.py @@ -1,5 +1,6 @@ """Interfaces with multimatic sensors.""" import logging +from typing import Optional from pymultimatic.model import Report @@ -10,8 +11,8 @@ ) from homeassistant.const import TEMP_CELSIUS -from . import ApiHub -from .const import DOMAIN as MULTIMATIC, HUB +from . import MultimaticDataUpdateCoordinator +from .const import COORDINATOR, DOMAIN as MULTIMATIC from .entities import MultimaticEntity _LOGGER = logging.getLogger(__name__) @@ -26,14 +27,15 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the multimatic sensors.""" sensors = [] - hub = hass.data[MULTIMATIC][entry.unique_id][HUB] + coordinator = hass.data[MULTIMATIC][entry.unique_id][COORDINATOR] - if hub.data: - if hub.data.outdoor_temperature: - sensors.append(OutdoorTemperatureSensor(hub)) + if coordinator.data: + if coordinator.data.outdoor_temperature: + sensors.append(OutdoorTemperatureSensor(coordinator)) - for report in hub.data.reports: - sensors.append(ReportSensor(hub, report)) + sensors.extend( + ReportSensor(coordinator, report) for report in coordinator.data.reports + ) _LOGGER.info("Adding %s sensor entities", len(sensors)) @@ -44,9 +46,9 @@ async def async_setup_entry(hass, entry, async_add_entities): class OutdoorTemperatureSensor(MultimaticEntity): """Outdoor temperature sensor.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Initialize entity.""" - super().__init__(hub, DOMAIN, "outdoor", "Outdoor", DEVICE_CLASS_TEMPERATURE) + super().__init__(coordinator, DOMAIN, "outdoor_temperature") @property def state(self): @@ -65,27 +67,29 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return TEMP_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 DEVICE_CLASS_TEMPERATURE + class ReportSensor(MultimaticEntity): """Report sensor.""" - def __init__(self, hub: ApiHub, report: Report): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator, report: Report): """Init entity.""" - device_class = UNIT_TO_DEVICE_CLASS.get(report.unit, None) - if not device_class: - _LOGGER.warning("No device class for %s", report.unit) - MultimaticEntity.__init__( - self, hub, DOMAIN, report.id, report.name, device_class, False - ) + MultimaticEntity.__init__(self, coordinator, DOMAIN, report.id) self._report_id = report.id @property def report(self): """Get the current report based on the id.""" - for report in self.coordinator.data.reports: - if self._report_id == report.id: - return report - return None + return self.coordinator.get_report(self._report_id) @property def state(self): @@ -98,19 +102,31 @@ def available(self): return super().available and self.report is not None @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> Optional[str]: """Return the unit of measurement of this entity, if any.""" - return self.report.unit + return self.report.unit if self.report else None @property def device_info(self): """Return device specific attributes.""" - if self.report is not None: - return { - "identifiers": { - (DOMAIN, self.report.device_id, self.coordinator.serial) - }, - "name": self.report.device_name, - "manufacturer": "Vaillant", - } - return None + return { + "identifiers": { + ( + DOMAIN, + self.report.device_id, + self.coordinator.data.info.serial_number, + ) + }, + "name": self.report.device_name, + "manufacturer": "Vaillant", + } + + @property + def device_class(self) -> Optional[str]: + """Return the class of this device, from component DEVICE_CLASSES.""" + return UNIT_TO_DEVICE_CLASS.get(self.report.unit, None) + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return self.report.name if self.report else None diff --git a/custom_components/multimatic/service.py b/custom_components/multimatic/service.py index 2b256aa..72a52c0 100644 --- a/custom_components/multimatic/service.py +++ b/custom_components/multimatic/service.py @@ -7,7 +7,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.util.dt import parse_date -from . import ApiHub +from . import MultimaticDataUpdateCoordinator from .const import ( ATTR_DURATION, ATTR_END_DATE, @@ -87,7 +87,7 @@ class MultimaticServiceHandler: """Service implementation.""" - def __init__(self, hub: ApiHub, hass) -> None: + def __init__(self, hub: MultimaticDataUpdateCoordinator, hass) -> None: """Init.""" self._hub = hub self._hass = hass diff --git a/custom_components/multimatic/strings.json b/custom_components/multimatic/strings.json index ee68e96..5dee176 100644 --- a/custom_components/multimatic/strings.json +++ b/custom_components/multimatic/strings.json @@ -6,6 +6,7 @@ "data": { "password": "Password", "username": "Username", + "Application": "Application", "serial_number": "Serial number" }, "title": "Connection information (same as multiMATIC application)" diff --git a/custom_components/multimatic/water_heater.py b/custom_components/multimatic/water_heater.py index 56095ce..9776d7a 100644 --- a/custom_components/multimatic/water_heater.py +++ b/custom_components/multimatic/water_heater.py @@ -12,10 +12,9 @@ ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from . import ApiHub -from .const import DOMAIN as MULTIMATIC, HUB +from . import MultimaticDataUpdateCoordinator +from .const import COORDINATOR, DOMAIN as MULTIMATIC from .entities import MultimaticEntity -from .utils import gen_state_attrs _LOGGER = logging.getLogger(__name__) @@ -36,11 +35,10 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up water_heater platform.""" entities = [] - hub = hass.data[MULTIMATIC][entry.unique_id][HUB] + coordinator = hass.data[MULTIMATIC][entry.unique_id][COORDINATOR] - if hub.data and hub.data.dhw and hub.data.dhw.hotwater: - entity = MultimaticWaterHeater(hub) - entities.append(entity) + if coordinator.data and coordinator.data.dhw and coordinator.data.dhw.hotwater: + entities.append(MultimaticWaterHeater(coordinator)) async_add_entities(entities) return True @@ -49,13 +47,16 @@ async def async_setup_entry(hass, entry, async_add_entities): class MultimaticWaterHeater(MultimaticEntity, WaterHeaterEntity): """Represent the multimatic water heater.""" - def __init__(self, hub: ApiHub): + def __init__(self, coordinator: MultimaticDataUpdateCoordinator): """Initialize entity.""" - super().__init__( - hub, DOMAIN, hub.data.dhw.hotwater.id, hub.data.dhw.hotwater.name - ) + super().__init__(coordinator, DOMAIN, coordinator.data.dhw.hotwater.id) self._operations = {mode.name: mode for mode in HotWater.MODES} + @property + def name(self) -> str: + """Return the name of the entity.""" + return self.component.name if self.component else None + @property def listening(self): """Return whether this entity is listening for system changes or not.""" @@ -110,16 +111,6 @@ def temperature_unit(self): """Return the unit of measurement used by the platform.""" return TEMP_CELSIUS - @property - def state_attributes(self): - """Return the optional state attributes. - - Adding current temperature - """ - attrs = super().state_attributes - attrs.update(gen_state_attrs(self.component, self.active_mode)) - return attrs - @property def target_temperature(self): """Return the temperature we try to reach."""