From e44fde74d0cba885f310cfaaaf212a45565d1bfc Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Sun, 7 Jul 2024 18:33:42 +1000 Subject: [PATCH] MyPyllant v0.8.11 --- custom_components/mypyllant/__init__.py | 5 +- custom_components/mypyllant/binary_sensor.py | 82 +++- custom_components/mypyllant/calendar.py | 102 ++++- custom_components/mypyllant/climate.py | 392 +++++++++++++----- custom_components/mypyllant/config_flow.py | 36 ++ custom_components/mypyllant/const.py | 15 + custom_components/mypyllant/coordinator.py | 19 +- custom_components/mypyllant/datetime.py | 79 +++- custom_components/mypyllant/manifest.json | 4 +- custom_components/mypyllant/number.py | 35 +- custom_components/mypyllant/sensor.py | 69 +-- custom_components/mypyllant/services.yaml | 54 +++ custom_components/mypyllant/switch.py | 105 ++++- .../mypyllant/translations/cs.json | 21 +- .../mypyllant/translations/de.json | 21 +- .../mypyllant/translations/en.json | 21 +- .../mypyllant/translations/it.json | 21 +- .../mypyllant/translations/pl.json | 21 +- .../mypyllant/translations/sk.json | 21 +- custom_components/mypyllant/utils.py | 52 ++- .../mypyllant/ventilation_climate.py | 2 +- custom_components/mypyllant/water_heater.py | 45 +- 22 files changed, 1041 insertions(+), 181 deletions(-) diff --git a/custom_components/mypyllant/__init__.py b/custom_components/mypyllant/__init__.py index d4e79060..5571b963 100644 --- a/custom_components/mypyllant/__init__.py +++ b/custom_components/mypyllant/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from datetime import datetime as dt, timedelta +from datetime import datetime as dt, timedelta, datetime import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -133,6 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def handle_export(call: ServiceCall) -> ServiceResponse: + _LOGGER.debug("Exporting data with params %s", call.data) return { "export": await export.main( user=username, @@ -163,7 +164,7 @@ async def handle_report(call: ServiceCall) -> ServiceResponse: password=password, brand=brand, country=country, - year=call.data.get("year"), + year=int(call.data.get("year", datetime.now().year)), write_results=False, ) } diff --git a/custom_components/mypyllant/binary_sensor.py b/custom_components/mypyllant/binary_sensor.py index 37ffa400..75562589 100644 --- a/custom_components/mypyllant/binary_sensor.py +++ b/custom_components/mypyllant/binary_sensor.py @@ -17,7 +17,7 @@ from . import SystemCoordinator from .const import DOMAIN -from .utils import EntityList +from .utils import EntityList, ZoneCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -39,12 +39,20 @@ async def async_setup_entry( sensors.append(lambda: ControlOnline(index, coordinator)) sensors.append(lambda: FirmwareUpdateRequired(index, coordinator)) sensors.append(lambda: FirmwareUpdateEnabled(index, coordinator)) - for circuit_index, circuit in enumerate(system.circuits): + if system.eebus: + sensors.append(lambda: EebusEnabled(index, coordinator)) + sensors.append(lambda: EebusCapable(index, coordinator)) + for circuit_index, _ in enumerate(system.circuits): sensors.append( lambda: CircuitIsCoolingAllowed(index, circuit_index, coordinator) ) + for zone_index, zone in enumerate(system.zones): + if zone.is_manual_cooling_active is not None: + sensors.append( + lambda: ZoneIsManualCoolingActive(index, zone_index, coordinator) + ) - async_add_entities(sensors) + async_add_entities(sensors) # type: ignore class SystemControlEntity(CoordinatorEntity, BinarySensorEntity): @@ -180,6 +188,60 @@ def unique_id(self) -> str: return f"{DOMAIN}_{self.id_infix}_firmware_update_enabled" +class EebusCapable(SystemControlEntity): + _attr_icon = "mdi:check-network" + + def __init__( + self, + system_index: int, + coordinator: SystemCoordinator, + ): + super().__init__(system_index, coordinator) + + @property + def is_on(self) -> bool | None: + return ( + self.system.eebus.get("spine_capable", False) + if self.system.eebus + else False + ) + + @property + def name(self) -> str: + return f"{self.name_prefix} EEBUS Capable" + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.id_infix}_eebus_capable" + + +class EebusEnabled(SystemControlEntity): + _attr_icon = "mdi:check-network" + + def __init__( + self, + system_index: int, + coordinator: SystemCoordinator, + ): + super().__init__(system_index, coordinator) + + @property + def is_on(self) -> bool | None: + return ( + self.system.eebus.get("spine_enabled", False) + if self.system.eebus + else False + ) + + @property + def name(self) -> str: + return f"{self.name_prefix} EEBUS Enabled" + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.id_infix}_eebus_enabled" + + class CircuitEntity(CoordinatorEntity, BinarySensorEntity): def __init__( self, @@ -236,3 +298,17 @@ def name(self) -> str: @property def unique_id(self) -> str: return f"{DOMAIN} {self.id_infix}_cooling_allowed" + + +class ZoneIsManualCoolingActive(ZoneCoordinatorEntity, BinarySensorEntity): + @property + def is_on(self) -> bool | None: + return self.zone.is_manual_cooling_active + + @property + def name(self) -> str: + return f"{self.name_prefix} Manual Cooling Active" + + @property + def unique_id(self) -> str: + return f"{DOMAIN} {self.id_infix}_manual_cooling_active" diff --git a/custom_components/mypyllant/calendar.py b/custom_components/mypyllant/calendar.py index fe13618a..44e9bb77 100644 --- a/custom_components/mypyllant/calendar.py +++ b/custom_components/mypyllant/calendar.py @@ -3,6 +3,8 @@ import copy import datetime import logging +import re +from abc import ABC, abstractmethod from typing import Any from homeassistant.components.calendar import ( @@ -24,8 +26,9 @@ ZoneTimeProgram, RoomTimeProgramDay, RoomTimeProgram, + System, ) -from myPyllant.enums import ZoneTimeProgramType +from myPyllant.enums import ZoneOperatingType from . import SystemCoordinator from .const import DOMAIN, WEEKDAYS_TO_RFC5545, RFC5545_TO_WEEKDAYS @@ -57,6 +60,10 @@ async def async_setup_entry( sensors.append( lambda: ZoneHeatingCalendar(index, zone_index, coordinator) ) + if zone.cooling and zone.cooling.time_program_cooling: + sensors.append( + lambda: ZoneCoolingCalendar(index, zone_index, coordinator) + ) for dhw_index, dhw in enumerate(system.domestic_hot_water): sensors.append( lambda: DomesticHotWaterCalendar(index, dhw_index, coordinator) @@ -70,10 +77,10 @@ async def async_setup_entry( sensors.append( lambda: AmbisenseCalendar(index, room.room_index, coordinator) ) - async_add_entities(sensors) + async_add_entities(sensors) # type: ignore -class BaseCalendarEntity(CalendarEntity): +class BaseCalendarEntity(CalendarEntity, ABC): _attr_supported_features = ( CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT @@ -104,18 +111,22 @@ def _get_rrule(self, time_program_day: BaseTimeProgramDay) -> str: matching_weekdays = self.time_program.matching_weekdays(time_program_day) return f"FREQ=WEEKLY;INTERVAL=1;BYDAY={','.join([WEEKDAYS_TO_RFC5545[d] for d in matching_weekdays])}" - def _get_weekdays_from_rrule(self, rrule: str) -> list[str]: + @staticmethod + def _get_weekdays_from_rrule(rrule: str) -> list[str]: by_day = [p for p in rrule.split(";") if p.startswith("BYDAY=")][0].replace( "BYDAY=", "" ) return [RFC5545_TO_WEEKDAYS[d] for d in by_day.split(",")] - def get_setpoint_from_summary(self, summary: str): + @staticmethod + def get_setpoint_from_summary(summary: str) -> float: + """ + Extracts a float temperature value from a string such as "Heating to 21.0°C on Home Zone 1 (Circuit 0)" + """ + match = re.search(r"([0-9.,]+)°?C?", summary) try: - if " " in summary: - summary = summary.split(" ")[0] - return float(summary.replace("°C", "")) - except ValueError: + return float(match.group(1).replace(",", ".")) # type: ignore + except (ValueError, AttributeError): raise HomeAssistantError("Invalid setpoint, use format '21.5°C' in Summary") def _check_overlap(self): @@ -135,6 +146,11 @@ def build_event( async def update_time_program(self): raise NotImplementedError + @property + @abstractmethod + def system(self) -> System: + pass + @property def event(self) -> CalendarEvent | None: start = datetime.datetime.now(self.system.timezone) @@ -254,8 +270,8 @@ async def async_update_event( await self.update_time_program() -class ZoneHeatingCalendar(BaseCalendarEntity, ZoneCoordinatorEntity): - _attr_icon = "mdi:thermometer-auto" +class ZoneHeatingCalendar(ZoneCoordinatorEntity, BaseCalendarEntity): + _attr_icon = "mdi:home-thermometer" _has_setpoint = True @property @@ -264,18 +280,23 @@ def time_program(self) -> ZoneTimeProgram: @property def name(self) -> str: - return f"{self.name_prefix} Heating Schedule" + return self.name_prefix def _get_calendar_id_prefix(self): return f"zone_heating_{self.zone.index}" + def _get_setpoint(self, time_program_day: ZoneTimeProgramDay): + if time_program_day.setpoint: + return time_program_day.setpoint + return self.zone.desired_room_temperature_setpoint_heating + def build_event( self, time_program_day: ZoneTimeProgramDay, start: datetime.datetime, end: datetime.datetime, ): - summary = f"{time_program_day.setpoint}°C on {self.name}" + summary = f"Heating to {self._get_setpoint(time_program_day)}°C on {self.name}" return CalendarEvent( summary=summary, start=start, @@ -288,13 +309,52 @@ def build_event( async def update_time_program(self): await self.coordinator.api.set_zone_time_program( - self.zone, str(ZoneTimeProgramType.HEATING), self.time_program + self.zone, str(ZoneOperatingType.HEATING), self.time_program + ) + await self.coordinator.async_request_refresh_delayed() + + +class ZoneCoolingCalendar(ZoneCoordinatorEntity, BaseCalendarEntity): + _attr_icon = "mdi:snowflake-thermometer" + _has_setpoint = True + + @property + def time_program(self) -> ZoneTimeProgram: + return self.zone.cooling.time_program_cooling # type: ignore + + @property + def name(self) -> str: + return self.name_prefix + + def _get_calendar_id_prefix(self): + return f"zone_cooling_{self.zone.index}" + + def build_event( + self, + time_program_day: ZoneTimeProgramDay, + start: datetime.datetime, + end: datetime.datetime, + ): + summary = f"Cooling to {self.zone.desired_room_temperature_setpoint_cooling}°C on {self.name}" + return CalendarEvent( + summary=summary, + start=start, + end=end, + description="You can change the start time, end time, or weekdays. Temperature is the same for all slots.", + uid=self._get_uid(time_program_day, start), + rrule=self._get_rrule(time_program_day), + recurrence_id=self._get_recurrence_id(time_program_day), + ) + + async def update_time_program(self): + await self.coordinator.api.set_zone_time_program( + self.zone, str(ZoneOperatingType.COOLING), self.time_program ) await self.coordinator.async_request_refresh_delayed() -class DomesticHotWaterCalendar(BaseCalendarEntity, DomesticHotWaterCoordinatorEntity): - _attr_icon = "mdi:water-boiler-auto" +class DomesticHotWaterCalendar(DomesticHotWaterCoordinatorEntity, BaseCalendarEntity): + _attr_icon = "mdi:water-thermometer" @property def time_program(self) -> DHWTimeProgram: @@ -302,7 +362,7 @@ def time_program(self) -> DHWTimeProgram: @property def name(self) -> str: - return f"{self.name_prefix} Schedule" + return self.name_prefix def _get_calendar_id_prefix(self): return f"dhw_{self.domestic_hot_water.index}" @@ -313,7 +373,7 @@ def build_event( start: datetime.datetime, end: datetime.datetime, ): - summary = f"{self.domestic_hot_water.tapping_setpoint}°C on {self.name}" + summary = f"Heating Water to {self.domestic_hot_water.tapping_setpoint}°C on {self.name}" return CalendarEvent( summary=summary, start=start, @@ -332,7 +392,7 @@ async def update_time_program(self): class DomesticHotWaterCirculationCalendar( - BaseCalendarEntity, DomesticHotWaterCoordinatorEntity + DomesticHotWaterCoordinatorEntity, BaseCalendarEntity ): _attr_icon = "mdi:pump" @@ -342,7 +402,7 @@ def time_program(self) -> DHWTimeProgram: @property def name(self) -> str: - return f"{self.name_prefix} Circulation Schedule" + return f"Circulating Water in {self.name_prefix}" def _get_calendar_id_prefix(self): return f"dhw_circulation_{self.domestic_hot_water.index}" @@ -371,7 +431,7 @@ async def update_time_program(self): await self.coordinator.async_request_refresh_delayed() -class AmbisenseCalendar(BaseCalendarEntity, AmbisenseCoordinatorEntity): +class AmbisenseCalendar(AmbisenseCoordinatorEntity, BaseCalendarEntity): _attr_icon = "mdi:thermometer-auto" _has_setpoint = True diff --git a/custom_components/mypyllant/climate.py b/custom_components/mypyllant/climate.py index e7fdfe7a..ba5e5918 100644 --- a/custom_components/mypyllant/climate.py +++ b/custom_components/mypyllant/climate.py @@ -13,12 +13,13 @@ HVACMode, HVACAction, PRESET_COMFORT, + ATTR_TARGET_TEMP_LOW, + ATTR_TARGET_TEMP_HIGH, ) from homeassistant.components.climate.const import ( PRESET_AWAY, PRESET_BOOST, PRESET_NONE, - PRESET_SLEEP, PRESET_ECO, ) from homeassistant.config_entries import ConfigEntry @@ -29,11 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import as_datetime from homeassistant.helpers.update_coordinator import CoordinatorEntity -from myPyllant.const import ( - DEFAULT_MANUAL_SETPOINT_TYPE, - DEFAULT_QUICK_VETO_DURATION, - ZONE_MANUAL_SETPOINT_TYPES, -) +from myPyllant.const import DEFAULT_QUICK_VETO_DURATION from myPyllant.models import ( System, Zone, @@ -41,11 +38,12 @@ RoomTimeProgram, ) from myPyllant.enums import ( - ZoneHeatingOperatingMode, - ZoneHeatingOperatingModeVRC700, + ZoneOperatingMode, + ZoneOperatingModeVRC700, ZoneCurrentSpecialFunction, CircuitState, AmbisenseRoomOperationMode, + ZoneOperatingType, ) from custom_components.mypyllant.utils import ( @@ -71,42 +69,30 @@ DEFAULT_HOLIDAY_SETPOINT, SERVICE_SET_ZONE_OPERATING_MODE, SERVICE_SET_TIME_PROGRAM, + SERVICE_SET_COOLING_FOR_DAYS, + SERVICE_CANCEL_COOLING_FOR_DAYS, + HVAC_MODE_COOLING_FOR_DAYS, + SERVICE_SET_TIME_CONTROLLED_COOLING_SETPOINT, + SERVICE_SET_VENTILATION_BOOST, + SERVICE_CANCEL_VENTILATION_BOOST, + DEFAULT_MANUAL_SETPOINT_TYPE, ) from .ventilation_climate import _FAN_STAGE_TYPE_OPTIONS, VentilationClimate _LOGGER = logging.getLogger(__name__) _ZONE_MANUAL_SETPOINT_TYPES_OPTIONS = [ - selector.SelectOptionDict(value=k, label=v) - for k, v in ZONE_MANUAL_SETPOINT_TYPES.items() + selector.SelectOptionDict(value=v.value, label=v.title()) + for v in list(ZoneOperatingType) ] _ZONE_OPERATING_MODE_OPTIONS = [ selector.SelectOptionDict(value=v, label=v.replace("_", " ").title()) for v in set( - [ - e.value - for e in list(ZoneHeatingOperatingMode) - + list(ZoneHeatingOperatingModeVRC700) - ] + [e.value for e in list(ZoneOperatingMode) + list(ZoneOperatingModeVRC700)] ) ] -ZONE_PRESET_MAP = { - PRESET_BOOST: ZoneCurrentSpecialFunction.QUICK_VETO, - PRESET_ECO: ZoneCurrentSpecialFunction.NONE, - PRESET_NONE: ZoneCurrentSpecialFunction.NONE, - PRESET_AWAY: ZoneCurrentSpecialFunction.HOLIDAY, - PRESET_SLEEP: ZoneCurrentSpecialFunction.SYSTEM_OFF, -} - -ZONE_PRESET_MAP_VRC700 = { - ZoneHeatingOperatingModeVRC700.OFF: PRESET_NONE, - ZoneHeatingOperatingModeVRC700.DAY: PRESET_COMFORT, - ZoneHeatingOperatingModeVRC700.AUTO: PRESET_NONE, - ZoneHeatingOperatingModeVRC700.SET_BACK: PRESET_ECO, -} - ZONE_HVAC_ACTION_MAP = { CircuitState.STANDBY: HVACAction.IDLE, CircuitState.HEATING: HVACAction.HEATING, @@ -172,9 +158,9 @@ async def async_setup_entry( ) ) - async_add_entities(zone_entities) - async_add_entities(ventilation_entities) - async_add_entities(ambisense_entities) + async_add_entities(zone_entities) # type: ignore + async_add_entities(ventilation_entities) # type: ignore + async_add_entities(ambisense_entities) # type: ignore if len(zone_entities) > 0 or len(ambisense_entities) > 0: platform = entity_platform.async_get_current_platform() @@ -210,6 +196,15 @@ async def async_setup_entry( }, "set_manual_mode_setpoint", ) + platform.async_register_entity_service( + SERVICE_SET_TIME_CONTROLLED_COOLING_SETPOINT, + { + vol.Required("temperature"): vol.All( + vol.Coerce(float), vol.Clamp(min=0, max=30) + ), + }, + "set_time_controlled_cooling_setpoint", + ) platform.async_register_entity_service( SERVICE_CANCEL_QUICK_VETO, {}, @@ -236,6 +231,34 @@ async def async_setup_entry( ) # noinspection PyTypeChecker # Wrapping the schema in vol.Schema() breaks entity_id passing + platform.async_register_entity_service( + SERVICE_SET_COOLING_FOR_DAYS, + { + vol.Optional("start"): vol.Coerce(as_datetime), + vol.Optional("end"): vol.Coerce(as_datetime), + vol.Optional("duration_days"): vol.All( + vol.Coerce(int), vol.Clamp(min=1) + ), + }, + "set_cooling_for_days", + ) + platform.async_register_entity_service( + SERVICE_CANCEL_COOLING_FOR_DAYS, + {}, + "cancel_cooling_for_days", + ) + platform.async_register_entity_service( + SERVICE_SET_VENTILATION_BOOST, + {}, + "set_ventilation_boost", + ) + platform.async_register_entity_service( + SERVICE_CANCEL_VENTILATION_BOOST, + {}, + "cancel_ventilation_boost", + ) + # noinspection PyTypeChecker + # Wrapping the schema in vol.Schema() breaks entity_id passing platform.async_register_entity_service( SERVICE_SET_TIME_PROGRAM, { @@ -321,7 +344,9 @@ class ZoneClimate(CoordinatorEntity, ClimateEntity): """Climate for a zone.""" coordinator: SystemCoordinator + _attr_translation_key = "mypyllant_zone" _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 0.5 _enable_turn_on_off_backwards_compatibility = False def __init__( @@ -362,17 +387,41 @@ def hvac_modes(self) -> list[HVACMode]: def hvac_mode_map(self): if self.zone.control_identifier.is_vrc700: return { - ZoneHeatingOperatingModeVRC700.OFF: HVACMode.OFF, - ZoneHeatingOperatingModeVRC700.AUTO: HVACMode.AUTO, - ZoneHeatingOperatingModeVRC700.DAY: HVACMode.AUTO, - ZoneHeatingOperatingModeVRC700.SET_BACK: HVACMode.AUTO, + ZoneOperatingModeVRC700.OFF: HVACMode.OFF, + ZoneOperatingModeVRC700.AUTO: HVACMode.AUTO, + ZoneOperatingModeVRC700.DAY: HVACMode.AUTO, + ZoneOperatingModeVRC700.SET_BACK: HVACMode.AUTO, } else: + mode_map = { + ZoneOperatingMode.OFF: HVACMode.OFF, + ZoneOperatingMode.MANUAL: HVACMode.HEAT_COOL, + ZoneOperatingMode.TIME_CONTROLLED: HVACMode.AUTO, + } + if self.zone.is_cooling_allowed_circuit: + mode_map[HVAC_MODE_COOLING_FOR_DAYS] = HVACMode.COOL + return mode_map + + @property + def preset_mode_map(self): + if self.zone.control_identifier.is_vrc700: return { - ZoneHeatingOperatingMode.OFF: HVACMode.OFF, - ZoneHeatingOperatingMode.MANUAL: HVACMode.HEAT_COOL, - ZoneHeatingOperatingMode.TIME_CONTROLLED: HVACMode.AUTO, + ZoneOperatingModeVRC700.OFF: PRESET_NONE, + ZoneOperatingModeVRC700.DAY: PRESET_COMFORT, + ZoneOperatingModeVRC700.AUTO: PRESET_NONE, + ZoneOperatingModeVRC700.SET_BACK: PRESET_ECO, + } + else: + preset_modes = { + PRESET_BOOST: ZoneCurrentSpecialFunction.QUICK_VETO, + PRESET_NONE: ZoneCurrentSpecialFunction.NONE, + PRESET_AWAY: ZoneCurrentSpecialFunction.HOLIDAY, + "system_off": ZoneCurrentSpecialFunction.SYSTEM_OFF, + "ventilation_boost": ZoneCurrentSpecialFunction.VENTILATION_BOOST, } + if self.zone.is_eco_mode: + preset_modes[PRESET_ECO] = ZoneCurrentSpecialFunction.NONE + return preset_modes @property def default_quick_veto_duration(self): @@ -468,6 +517,49 @@ async def cancel_holiday(self): await self.coordinator.api.cancel_holiday(self.system) await self.coordinator.async_request_refresh_delayed(20) + async def set_cooling_for_days(self, **kwargs): + _LOGGER.debug( + "Setting cooling for days on System %s with params %s", + self.system.id, + kwargs, + ) + start = kwargs.get("start") + end = kwargs.get("end") + duration_days = kwargs.get("duration_days") + await self.coordinator.api.set_cooling_for_days( + self.system, start, end, duration_days + ) + await self.coordinator.async_request_refresh_delayed(20) + + async def cancel_cooling_for_days(self, **kwargs): + _LOGGER.debug( + "Canceling cooling for days on System %s", + self.system.id, + ) + await self.coordinator.api.cancel_cooling_for_days(self.system) + await self.coordinator.async_request_refresh_delayed(20) + + async def set_ventilation_boost(self, **kwargs): + _LOGGER.debug( + "Setting ventilation boost on System %s with params %s", + self.system.id, + kwargs, + ) + if self.system.control_identifier.is_vrc700: + raise ValueError("Can't set ventilation boost on VRC700 systems") + await self.coordinator.api.set_ventilation_boost(self.system) + await self.coordinator.async_request_refresh_delayed(20) + + async def cancel_ventilation_boost(self, **kwargs): + _LOGGER.debug( + "Canceling ventilation boost on System %s", + self.system.id, + ) + if self.system.control_identifier.is_vrc700: + raise ValueError("Can't cancel ventilation boost on VRC700 systems") + await self.coordinator.api.cancel_ventilation_boost(self.system) + await self.coordinator.async_request_refresh_delayed(20) + async def set_time_program(self, **kwargs): _LOGGER.debug("Setting time program on %s", self.zone) program_type = kwargs.get("program_type") @@ -504,16 +596,40 @@ async def set_manual_mode_setpoint(self, **kwargs): ) await self.coordinator.async_request_refresh_delayed() + async def set_time_controlled_cooling_setpoint(self, **kwargs): + _LOGGER.debug( + f"Setting time controlled setpoint temperature on {self.zone.name} with params {kwargs}" + ) + temperature = kwargs.get("temperature") + await self.coordinator.api.set_time_controlled_cooling_setpoint( + self.zone, temperature + ) + await self.coordinator.async_request_refresh_delayed(10) + async def remove_quick_veto(self): _LOGGER.debug("Removing quick veto on %s", self.zone.name) await self.coordinator.api.cancel_quick_veto_zone_temperature(self.zone) await self.coordinator.async_request_refresh_delayed() + @property + def supports_target_temperature_range(self) -> bool: + return ( + self.zone.is_cooling_allowed_circuit is True + and self.zone.desired_room_temperature_setpoint_heating is not None + and self.zone.desired_room_temperature_setpoint_cooling is not None + and self.zone.desired_room_temperature_setpoint_heating > 0 + and self.zone.desired_room_temperature_setpoint_cooling > 0 + ) + @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" + if self.supports_target_temperature_range: + target_temperature_feature = ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + else: + target_temperature_feature = ClimateEntityFeature.TARGET_TEMPERATURE return ( - ClimateEntityFeature.TARGET_TEMPERATURE + target_temperature_feature | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON @@ -521,10 +637,22 @@ def supported_features(self) -> ClimateEntityFeature: @property def target_temperature(self) -> float | None: - if self.zone.is_eco_mode: - return self.zone.heating.set_back_temperature return self.zone.desired_room_temperature_setpoint + @property + def target_temperature_low(self) -> float | None: + if self.supports_target_temperature_range: + return self.zone.desired_room_temperature_setpoint_heating + else: + return None + + @property + def target_temperature_high(self) -> float | None: + if self.supports_target_temperature_range: + return self.zone.desired_room_temperature_setpoint_cooling + else: + return None + @property def current_temperature(self) -> float | None: return self.zone.current_room_temperature @@ -535,16 +663,29 @@ def current_humidity(self) -> float | None: @property def hvac_mode(self) -> HVACMode: - return self.hvac_mode_map.get(self.zone.heating.operation_mode_heating) + if self.system.manual_cooling_ongoing: + return self.hvac_mode_map.get(HVAC_MODE_COOLING_FOR_DAYS) + return self.hvac_mode_map.get(self.zone.active_operation_mode) async def async_set_hvac_mode(self, hvac_mode: HVACMode): - mode = [k for k, v in self.hvac_mode_map.items() if v == hvac_mode][0] - await self.set_zone_operating_mode(mode) + if hvac_mode == HVACMode.COOL: + if not self.system.manual_cooling_ongoing: + await self.set_cooling_for_days() + else: + refresh_delay = 10 + if self.system.manual_cooling_ongoing: + await self.coordinator.api.cancel_cooling_for_days(self.system) + refresh_delay = 20 + mode = [k for k, v in self.hvac_mode_map.items() if v == hvac_mode][0] + await self.set_zone_operating_mode( + mode, self.zone.active_operating_type, refresh_delay=refresh_delay + ) async def set_zone_operating_mode( self, - mode: ZoneHeatingOperatingMode | ZoneHeatingOperatingModeVRC700 | str, + mode: ZoneOperatingMode | ZoneOperatingModeVRC700 | str, operating_type: str = "heating", + refresh_delay: int | None = None, ): """ Set operating mode for either cooling and heating HVAC mode @@ -553,28 +694,38 @@ async def set_zone_operating_mode( Parameters: mode: The new operating mode to set operating_type: Whether to set the mode for cooling or heating + refresh_delay: How long to wait before refreshing the data """ if self.zone.control_identifier.is_vrc700: - if mode not in ZoneHeatingOperatingModeVRC700: + if mode not in ZoneOperatingModeVRC700: raise ValueError( - f"Invalid mode, use one of {', '.join(ZoneHeatingOperatingModeVRC700)}" + f"Invalid mode, use one of {', '.join(ZoneOperatingModeVRC700)}" ) else: - if mode not in ZoneHeatingOperatingMode: + if mode not in ZoneOperatingMode: raise ValueError( - f"Invalid mode, use one of {', '.join(ZoneHeatingOperatingMode)}" + f"Invalid mode, use one of {', '.join(ZoneOperatingMode)}" ) + _LOGGER.debug( + "Setting %s on %s to %s", + operating_type, + self.zone.name, + mode, + ) await self.coordinator.api.set_zone_operating_mode( self.zone, mode, operating_type, ) - await self.coordinator.async_request_refresh_delayed() + await self.coordinator.async_request_refresh_delayed(delay=refresh_delay) @property def hvac_action(self) -> HVACAction | None: - circuit_state = self.zone.get_associated_circuit(self.system).circuit_state - return ZONE_HVAC_ACTION_MAP.get(circuit_state) + if self.zone.associated_circuit: + circuit_state = self.zone.associated_circuit.circuit_state + return ZONE_HVAC_ACTION_MAP.get(circuit_state) + else: + return None async def async_turn_on(self) -> None: await self.async_set_hvac_mode(self.data["last_active_hvac_mode"]) @@ -591,50 +742,70 @@ async def async_set_temperature(self, **kwargs: Any) -> None: "Setting temperature on %s with params %s", self.zone.name, kwargs ) temperature = kwargs.get(ATTR_TEMPERATURE) - if not temperature: - return - - if self.zone.heating.operation_mode_heating == ZoneHeatingOperatingMode.MANUAL: - _LOGGER.debug( - f"Setting manual mode setpoint on {self.zone.name} to {temperature}" - ) - await self.set_manual_mode_setpoint(temperature=temperature) - else: - if self.time_program_overwrite and not self.preset_mode == PRESET_BOOST: - _LOGGER.debug( - "Setting time program temperature in %s to %s", - self.zone.name, - temperature, - ) - await self.coordinator.api.set_time_program_temperature( - self.zone, - "heating", # TODO: Cooling? - temperature=temperature, - ) - await self.coordinator.async_request_refresh_delayed() + target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + + if target_temp_low is not None and target_temp_high is not None: + _LOGGER.debug("Setting target temperature range on %s", self.zone.name) + if target_temp_low != self.zone.desired_room_temperature_setpoint_heating: + await self.set_quick_veto(temperature=target_temp_low) + if ( + self.zone.cooling + and self.zone.cooling.operation_mode_cooling == ZoneOperatingMode.MANUAL + ): + if target_temp_high != self.zone.cooling.manual_mode_setpoint_cooling: + await self.set_manual_mode_setpoint( + temperature=target_temp_high, + setpoint_type="cooling", + ) + elif ( + self.zone.cooling + and self.zone.cooling.operation_mode_cooling + == ZoneOperatingMode.TIME_CONTROLLED + ): + if target_temp_high != self.zone.cooling.setpoint_cooling: + await self.set_time_controlled_cooling_setpoint( + temperature=target_temp_high + ) + elif temperature is not None: + if self.zone.heating.operation_mode_heating == ZoneOperatingMode.MANUAL: + await self.set_manual_mode_setpoint(temperature=temperature) else: - _LOGGER.debug( - "Setting quick veto on %s to %s", self.zone.name, temperature - ) - await self.set_quick_veto(temperature=temperature) + if self.time_program_overwrite and not self.preset_mode == PRESET_BOOST: + _LOGGER.debug( + "Setting time program temperature in %s to %s", + self.zone.name, + temperature, + ) + await self.coordinator.api.set_time_program_temperature( + self.zone, + "heating", + temperature=temperature, + ) + await self.coordinator.async_request_refresh_delayed() + else: + _LOGGER.debug( + "Setting quick veto on %s to %s", self.zone.name, temperature + ) + await self.set_quick_veto(temperature=temperature) @property def preset_modes(self) -> list[str]: if self.zone.control_identifier.is_vrc700: - return list({v for v in ZONE_PRESET_MAP_VRC700.values()}) + return list({v for v in self.preset_mode_map.values()}) else: - return [k for k in ZONE_PRESET_MAP.keys()] + return list(self.preset_mode_map.keys()) @property def preset_mode(self) -> str: if self.zone.control_identifier.is_vrc700: - return ZONE_PRESET_MAP_VRC700[self.zone.heating.operation_mode_heating] # type: ignore + return self.preset_mode_map[self.zone.active_operation_mode] # type: ignore else: if self.zone.is_eco_mode: return PRESET_ECO return [ k - for k, v in ZONE_PRESET_MAP.items() + for k, v in self.preset_mode_map.items() if v == self.zone.current_special_function ][0] @@ -649,38 +820,46 @@ async def async_set_preset_mode(self, preset_mode): # VRC700 presets map to operating modes instead of special functions if preset_mode == PRESET_NONE: # None can map to off or auto mode, if it's selected by the user we want auto - requested_mode = ZoneHeatingOperatingModeVRC700.AUTO - elif preset_mode in ZONE_PRESET_MAP_VRC700.values(): + requested_mode = ZoneOperatingModeVRC700.AUTO + elif preset_mode in self.preset_mode_map.values(): requested_mode = [ - k for k, v in ZONE_PRESET_MAP_VRC700.items() if v == preset_mode + k for k, v in self.preset_mode_map.items() if v == preset_mode ][0] else: raise ValueError( - f'Invalid preset mode, use one of {", ".join(set(ZONE_PRESET_MAP_VRC700.values()))}' + f'Invalid preset mode, use one of {", ".join(set(self.preset_mode_map.values()))}' ) await self.set_zone_operating_mode(requested_mode) else: - if preset_mode not in ZONE_PRESET_MAP: + if preset_mode not in self.preset_mode_map: raise ValueError( - f'Invalid preset mode, use one of {", ".join(ZONE_PRESET_MAP.keys())}' + f'Invalid preset mode {preset_mode}, use one of {", ".join(self.preset_mode_map.keys())}' ) - requested_mode = ZONE_PRESET_MAP[preset_mode] + requested_mode = self.preset_mode_map[preset_mode] if requested_mode != self.zone.current_special_function: - if requested_mode == ZoneCurrentSpecialFunction.NONE: - if ( - self.zone.current_special_function - == ZoneCurrentSpecialFunction.QUICK_VETO - ): - # If quick veto is set, we cancel that - await self.coordinator.api.cancel_quick_veto_zone_temperature( - self.zone - ) - elif ( - self.zone.current_special_function - == ZoneCurrentSpecialFunction.HOLIDAY - ): - # If holiday mode is set, we cancel that instead - await self.cancel_holiday() + # Cancel previous special function + if ( + self.zone.current_special_function + == ZoneCurrentSpecialFunction.QUICK_VETO + and requested_mode != ZoneCurrentSpecialFunction.QUICK_VETO + ): + await self.coordinator.api.cancel_quick_veto_zone_temperature( + self.zone + ) + elif ( + self.zone.current_special_function + == ZoneCurrentSpecialFunction.HOLIDAY + and requested_mode != ZoneCurrentSpecialFunction.HOLIDAY + ): + await self.cancel_holiday() + elif ( + self.zone.current_special_function + == ZoneCurrentSpecialFunction.VENTILATION_BOOST + and requested_mode != ZoneCurrentSpecialFunction.VENTILATION_BOOST + ): + await self.cancel_ventilation_boost() + + # Set new special function if requested_mode == ZoneCurrentSpecialFunction.QUICK_VETO: await self.coordinator.api.quick_veto_zone_temperature( self.zone, @@ -690,6 +869,9 @@ async def async_set_preset_mode(self, preset_mode): if requested_mode == ZoneCurrentSpecialFunction.HOLIDAY: await self.set_holiday() + if requested_mode == ZoneCurrentSpecialFunction.VENTILATION_BOOST: + await self.set_ventilation_boost() + if requested_mode == ZoneCurrentSpecialFunction.SYSTEM_OFF: # SYSTEM_OFF is a valid special function, but since there's no API endpoint we # just turn off the system though the zone heating mode API. diff --git a/custom_components/mypyllant/config_flow.py b/custom_components/mypyllant/config_flow.py index 9d3494d5..345b8b37 100644 --- a/custom_components/mypyllant/config_flow.py +++ b/custom_components/mypyllant/config_flow.py @@ -45,6 +45,14 @@ DEFAULT_FETCH_RTS, OPTION_FETCH_MPC, DEFAULT_FETCH_MPC, + OPTION_FETCH_AMBISENSE_ROOMS, + DEFAULT_FETCH_AMBISENSE_ROOMS, + OPTION_FETCH_ENERGY_MANAGEMENT, + DEFAULT_FETCH_ENERGY_MANAGEMENT, + OPTION_FETCH_EEBUS, + DEFAULT_FETCH_EEBUS, + OPTION_DEFAULT_MANUAL_COOLING_DURATION, + DEFAULT_MANUAL_COOLING_DURATION, ) _LOGGER = logging.getLogger(__name__) @@ -156,6 +164,13 @@ async def async_step_init( DEFAULT_HOLIDAY_SETPOINT, ), ): vol.All(vol.Coerce(float), vol.Clamp(min=0, max=30)), + vol.Required( + OPTION_DEFAULT_MANUAL_COOLING_DURATION, + default=self.config_entry.options.get( + OPTION_DEFAULT_MANUAL_COOLING_DURATION, + DEFAULT_MANUAL_COOLING_DURATION, + ), + ): positive_int, vol.Required( OPTION_TIME_PROGRAM_OVERWRITE, default=self.config_entry.options.get( @@ -199,6 +214,27 @@ async def async_step_init( DEFAULT_FETCH_MPC, ), ): bool, + vol.Required( + OPTION_FETCH_AMBISENSE_ROOMS, + default=self.config_entry.options.get( + OPTION_FETCH_AMBISENSE_ROOMS, + DEFAULT_FETCH_AMBISENSE_ROOMS, + ), + ): bool, + vol.Required( + OPTION_FETCH_ENERGY_MANAGEMENT, + default=self.config_entry.options.get( + OPTION_FETCH_ENERGY_MANAGEMENT, + DEFAULT_FETCH_ENERGY_MANAGEMENT, + ), + ): bool, + vol.Required( + OPTION_FETCH_EEBUS, + default=self.config_entry.options.get( + OPTION_FETCH_EEBUS, + DEFAULT_FETCH_EEBUS, + ), + ): bool, } ), ) diff --git a/custom_components/mypyllant/const.py b/custom_components/mypyllant/const.py index 0222ab37..47318625 100644 --- a/custom_components/mypyllant/const.py +++ b/custom_components/mypyllant/const.py @@ -1,9 +1,12 @@ +from myPyllant.enums import ZoneOperatingType + DOMAIN = "mypyllant" OPTION_UPDATE_INTERVAL = "update_interval" OPTION_UPDATE_INTERVAL_DAILY = "update_interval_daily" OPTION_REFRESH_DELAY = "refresh_delay" OPTION_DEFAULT_QUICK_VETO_DURATION = "quick_veto_duration" OPTION_DEFAULT_HOLIDAY_DURATION = "holiday_duration" +OPTION_DEFAULT_MANUAL_COOLING_DURATION = "manual_cooling_duration" OPTION_COUNTRY = "country" OPTION_BRAND = "brand" OPTION_TIME_PROGRAM_OVERWRITE = "time_program_overwrite" @@ -11,23 +14,35 @@ OPTION_FETCH_RTS = "fetch_rts" OPTION_FETCH_MPC = "fetch_mpc" OPTION_FETCH_AMBISENSE_ROOMS = "fetch_ambisense_rooms" +OPTION_FETCH_ENERGY_MANAGEMENT = "fetch_energy_management" +OPTION_FETCH_EEBUS = "fetch_eebus" DEFAULT_UPDATE_INTERVAL = 60 # in seconds DEFAULT_UPDATE_INTERVAL_DAILY = 3600 # in seconds DEFAULT_REFRESH_DELAY = 5 # in seconds +DEFAULT_MANUAL_COOLING_DURATION = 30 # in days DEFAULT_COUNTRY = "germany" DEFAULT_TIME_PROGRAM_OVERWRITE = False DEFAULT_HOLIDAY_SETPOINT = 10.0 # in °C DEFAULT_FETCH_RTS = False DEFAULT_FETCH_MPC = False DEFAULT_FETCH_AMBISENSE_ROOMS = True +DEFAULT_FETCH_ENERGY_MANAGEMENT = True +DEFAULT_FETCH_EEBUS = True +DEFAULT_MANUAL_SETPOINT_TYPE = ZoneOperatingType.HEATING QUOTA_PAUSE_INTERVAL = 3 * 3600 # in seconds API_DOWN_PAUSE_INTERVAL = 15 * 60 # in seconds +HVAC_MODE_COOLING_FOR_DAYS = "COOLING_FOR_DAYS" SERVICE_SET_QUICK_VETO = "set_quick_veto" SERVICE_SET_MANUAL_MODE_SETPOINT = "set_manual_mode_setpoint" +SERVICE_SET_TIME_CONTROLLED_COOLING_SETPOINT = "set_time_controlled_cooling_setpoint" SERVICE_CANCEL_QUICK_VETO = "cancel_quick_veto" SERVICE_SET_HOLIDAY = "set_holiday" SERVICE_CANCEL_HOLIDAY = "cancel_holiday" +SERVICE_SET_COOLING_FOR_DAYS = "set_cooling_for_days" +SERVICE_CANCEL_COOLING_FOR_DAYS = "cancel_cooling_for_days" +SERVICE_SET_VENTILATION_BOOST = "set_ventilation_boost" +SERVICE_CANCEL_VENTILATION_BOOST = "cancel_ventilation_boost" SERVICE_SET_TIME_PROGRAM = "set_time_program" SERVICE_SET_ZONE_TIME_PROGRAM = "set_zone_time_program" SERVICE_SET_ZONE_OPERATING_MODE = "set_zone_operating_mode" diff --git a/custom_components/mypyllant/coordinator.py b/custom_components/mypyllant/coordinator.py index 91a43d65..21fc0334 100644 --- a/custom_components/mypyllant/coordinator.py +++ b/custom_components/mypyllant/coordinator.py @@ -23,6 +23,10 @@ DEFAULT_FETCH_MPC, OPTION_FETCH_AMBISENSE_ROOMS, DEFAULT_FETCH_AMBISENSE_ROOMS, + OPTION_FETCH_ENERGY_MANAGEMENT, + DEFAULT_FETCH_ENERGY_MANAGEMENT, + OPTION_FETCH_EEBUS, + DEFAULT_FETCH_EEBUS, ) from custom_components.mypyllant.utils import is_quota_exceeded_exception from myPyllant.api import MyPyllantAPI @@ -152,9 +156,9 @@ def _raise_if_quota_hit(self) -> None: class SystemCoordinator(MyPyllantCoordinator): - data: list[System] + data: list[System] # type: ignore - async def _async_update_data(self) -> list[System]: + async def _async_update_data(self) -> list[System]: # type: ignore self._raise_if_quota_hit() include_connection_status = True include_diagnostic_trouble_codes = True @@ -163,6 +167,10 @@ async def _async_update_data(self) -> list[System]: include_ambisense_rooms = self.entry.options.get( OPTION_FETCH_AMBISENSE_ROOMS, DEFAULT_FETCH_AMBISENSE_ROOMS ) + include_energy_management = self.entry.options.get( + OPTION_FETCH_ENERGY_MANAGEMENT, DEFAULT_FETCH_ENERGY_MANAGEMENT + ) + include_eebus = self.entry.options.get(OPTION_FETCH_EEBUS, DEFAULT_FETCH_EEBUS) _LOGGER.debug("Starting async update data for SystemCoordinator") try: await self._refresh_session() @@ -175,6 +183,8 @@ async def _async_update_data(self) -> list[System]: include_rts, include_mpc, include_ambisense_rooms, + include_energy_management, + include_eebus, ) ] return data @@ -208,9 +218,10 @@ async def _async_update_data(self) -> dict[str, SystemWithDeviceData]: ) end = start + timedelta(days=1) _LOGGER.debug( - "Getting daily data for %s from %s to %s", system, start, end + "Getting daily data for %s from %s to %s", system.id, start, end ) if len(system.devices) == 0: + _LOGGER.debug("No devices in %s", system.id) continue data[system.id] = { "home_name": system.home.home_name or system.home.nomenclature, @@ -218,7 +229,7 @@ async def _async_update_data(self) -> dict[str, SystemWithDeviceData]: } for device in system.devices: device_data = self.api.get_data_by_device( - device, DeviceDataBucketResolution.DAY, start, end + device, DeviceDataBucketResolution.HOUR, start, end ) data[system.id]["devices_data"].append( [da async for da in device_data] diff --git a/custom_components/mypyllant/datetime.py b/custom_components/mypyllant/datetime.py index 29ce43d6..1f2f4400 100644 --- a/custom_components/mypyllant/datetime.py +++ b/custom_components/mypyllant/datetime.py @@ -10,7 +10,11 @@ from custom_components.mypyllant.const import DOMAIN, DEFAULT_HOLIDAY_SETPOINT from custom_components.mypyllant.coordinator import SystemCoordinator -from custom_components.mypyllant.utils import HolidayEntity, EntityList +from custom_components.mypyllant.utils import ( + HolidayEntity, + EntityList, + ManualCoolingEntity, +) from myPyllant.utils import get_default_holiday_dates _LOGGER = logging.getLogger(__name__) @@ -35,7 +39,16 @@ async def async_setup_entry( sensors.append( lambda: SystemHolidayEndDateTimeEntity(index, coordinator, config) ) - async_add_entities(sensors) + if not system.control_identifier.is_vrc700 and system.is_cooling_allowed: + sensors.append( + lambda: SystemManualCoolingStartDateTimeEntity( + index, coordinator, config + ) + ) + sensors.append( + lambda: SystemManualCoolingEndDateTimeEntity(index, coordinator, config) + ) + async_add_entities(sensors) # type: ignore class SystemHolidayStartDateTimeEntity(HolidayEntity, DateTimeEntity): @@ -103,3 +116,65 @@ async def async_set_value(self, value: datetime) -> None: @property def unique_id(self) -> str: return f"{DOMAIN}_{self.id_infix}_holiday_end_date_time" + + +class SystemManualCoolingStartDateTimeEntity(ManualCoolingEntity, DateTimeEntity): + _attr_icon = "mdi:snowflake-check" + + @property + def name(self): + return f"{self.name_prefix} Manual Cooling Start Date" + + @property + def native_value(self): + return ( + self.manual_cooling_start.replace(tzinfo=self.system.timezone) + if self.manual_cooling_start + else None + ) + + async def async_set_value(self, value: datetime) -> None: + _, end = get_default_holiday_dates( + self.manual_cooling_start, + self.manual_cooling_end, + self.system.timezone, + self.default_manual_cooling_duration, + ) + await self.coordinator.api.set_cooling_for_days( + self.system, + start=value, + end=end, + ) + await self.coordinator.async_request_refresh_delayed(20) + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.id_infix}_manual_cooling_start_date_time" + + +class SystemManualCoolingEndDateTimeEntity(SystemManualCoolingStartDateTimeEntity): + _attr_icon = "mdi:snowflake-off" + + @property + def name(self): + return f"{self.name_prefix} Manual Cooling End Date" + + @property + def native_value(self): + return ( + self.manual_cooling_end.replace(tzinfo=self.system.timezone) + if self.manual_cooling_end + else None + ) + + async def async_set_value(self, value: datetime) -> None: + await self.coordinator.api.set_cooling_for_days( + self.system, + start=self.manual_cooling_start, + end=value, + ) + await self.coordinator.async_request_refresh_delayed(20) + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.id_infix}_manual_cooling_end_date_time" diff --git a/custom_components/mypyllant/manifest.json b/custom_components/mypyllant/manifest.json index d0c480cb..03da4138 100644 --- a/custom_components/mypyllant/manifest.json +++ b/custom_components/mypyllant/manifest.json @@ -10,7 +10,7 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/signalkraft/mypyllant-component/issues", "requirements": [ - "myPyllant==0.8.12" + "myPyllant==0.8.23" ], - "version": "v0.7.3" + "version": "v0.8.11" } diff --git a/custom_components/mypyllant/number.py b/custom_components/mypyllant/number.py index b7720882..f5a5dc65 100644 --- a/custom_components/mypyllant/number.py +++ b/custom_components/mypyllant/number.py @@ -35,12 +35,14 @@ async def async_setup_entry( sensors: EntityList[NumberEntity] = EntityList() for index, system in enumerate(coordinator.data): sensors.append(lambda: SystemHolidayDurationNumber(index, coordinator)) + if system.is_cooling_allowed: + sensors.append(lambda: SystemManualCoolingDays(index, coordinator)) for zone_index, zone in enumerate(system.zones): sensors.append( lambda: ZoneQuickVetoDurationNumber(index, zone_index, coordinator) ) - async_add_entities(sensors) + async_add_entities(sensors) # type: ignore class SystemHolidayDurationNumber(HolidayEntity, NumberEntity): @@ -116,6 +118,37 @@ def unique_id(self) -> str: return f"{DOMAIN}_{self.id_infix}_holiday_duration_remaining" +class SystemManualCoolingDays(SystemCoordinatorEntity, NumberEntity): + _attr_native_unit_of_measurement = UnitOfTime.DAYS + _attr_icon = "mdi:snowflake" + _attr_step = 1 # type: ignore + + @property + def name(self): + return f"{self.name_prefix} Manual Cooling Duration" + + @property + def native_value(self): + return self.system.manual_cooling_days or 0 + + async def async_set_native_value(self, value: float) -> None: + if value == 0: + await self.coordinator.api.cancel_cooling_for_days(self.system) + else: + await self.coordinator.api.set_cooling_for_days( + self.system, duration_days=int(value) + ) + await self.coordinator.async_request_refresh_delayed(20) + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.id_infix}_manual_cooling_days" + + @property + def available(self) -> bool: + return self.system.is_cooling_allowed + + class ZoneQuickVetoDurationNumber(ZoneCoordinatorEntity, NumberEntity): _attr_native_unit_of_measurement = UnitOfTime.HOURS _attr_icon = "mdi:rocket-launch" diff --git a/custom_components/mypyllant/sensor.py b/custom_components/mypyllant/sensor.py index 529067f6..d57a7bc0 100644 --- a/custom_components/mypyllant/sensor.py +++ b/custom_components/mypyllant/sensor.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping -from typing import Any, Iterable, Sequence +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -26,7 +26,6 @@ Circuit, Device, DeviceData, - DeviceDataBucket, System, ) @@ -54,13 +53,13 @@ async def create_system_sensors( hass: HomeAssistant, config: ConfigEntry -) -> Sequence[SensorEntity]: +) -> EntityList[SensorEntity]: system_coordinator: SystemCoordinator = hass.data[DOMAIN][config.entry_id][ "system_coordinator" ] if not system_coordinator.data: _LOGGER.warning("No system data, skipping sensors") - return [] + return EntityList() sensors: EntityList[SensorEntity] = EntityList() _LOGGER.debug("Creating system sensors for %s", system_coordinator.data) @@ -203,7 +202,7 @@ async def create_system_sensors( async def create_daily_data_sensors( hass: HomeAssistant, config: ConfigEntry -) -> Iterable[SensorEntity]: +) -> EntityList[SensorEntity]: daily_data_coordinator: DailyDataCoordinator = hass.data[DOMAIN][config.entry_id][ "daily_data_coordinator" ] @@ -212,7 +211,7 @@ async def create_daily_data_sensors( if not daily_data_coordinator.data: _LOGGER.warning("No daily data, skipping sensors") - return [] + return EntityList() sensors: EntityList[SensorEntity] = EntityList() for system_id, system_devices in daily_data_coordinator.data.items(): @@ -246,8 +245,8 @@ async def create_daily_data_sensors( async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - async_add_entities(await create_system_sensors(hass, config)) - async_add_entities(await create_daily_data_sensors(hass, config)) + async_add_entities(await create_system_sensors(hass, config)) # type: ignore + async_add_entities(await create_daily_data_sensors(hass, config)) # type: ignore class SystemSensor(SystemCoordinatorEntity, SensorEntity): @@ -402,9 +401,22 @@ def system(self) -> System: @property def extra_state_attributes(self) -> Mapping[str, Any] | None: - rts = self.system.rts if self.system.rts else {} - mpc = self.system.mpc if self.system.mpc else {} - return self.system.home.extra_fields | self.system.extra_fields | rts | mpc + rts = {"rts": self.system.rts} if self.system.rts else {} + mpc = {"mpc": self.system.mpc} if self.system.mpc else {} + energy_management = ( + {"energy_management": self.system.energy_management} + if self.system.energy_management + else {} + ) + eebus = {"eebus": self.system.eebus} if self.system.eebus else {} + return ( + self.system.home.extra_fields + | self.system.extra_fields + | rts + | mpc + | energy_management + | eebus + ) @property def name_prefix(self) -> str: @@ -448,7 +460,9 @@ def name(self): @property def native_value(self): - if self.zone.desired_room_temperature_setpoint_heating: + if self.zone.desired_room_temperature_setpoint: + return self.zone.desired_room_temperature_setpoint + elif self.zone.desired_room_temperature_setpoint_heating: return self.zone.desired_room_temperature_setpoint_heating elif self.zone.desired_room_temperature_setpoint_cooling: return self.zone.desired_room_temperature_setpoint_cooling @@ -457,6 +471,15 @@ def native_value(self): return self.zone.heating.set_back_temperature return self.zone.desired_room_temperature_setpoint + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + return { + "desired_room_temperature_setpoint_heating": self.zone.desired_room_temperature_setpoint_heating, + "desired_room_temperature_setpoint_cooling": self.zone.desired_room_temperature_setpoint_cooling, + "desired_room_temperature_setpoint": self.zone.desired_room_temperature_setpoint, + "is_eco_mode": self.zone.is_eco_mode, + } + @property def unique_id(self) -> str: return f"{DOMAIN}_{self.id_infix}_desired_temperature" @@ -802,14 +825,10 @@ def device(self) -> Device | None: return self.device_data.device @property - def data_bucket(self) -> DeviceDataBucket | None: + def total_consumption(self) -> float | None: if self.device_data is None: return None - data = [d for d in self.device_data.data if d.value is not None] - if len(data) > 0: - return data[-1] - else: - return None + return self.device_data.total_consumption_rounded @property def unique_id(self) -> str | None: @@ -839,7 +858,7 @@ def device_info(self): @property def native_value(self): - return self.data_bucket.value if self.data_bucket else None + return self.device_data.total_consumption_rounded @callback def _handle_coordinator_update(self) -> None: @@ -886,11 +905,9 @@ def energy_consumed(self) -> float: """ return sum( [ - v.data[-1].value + v.total_consumption_rounded for v in self.device_data_list - if len(v.data) - and v.data[-1].value - and v.energy_type == "CONSUMED_ELECTRICAL_ENERGY" + if v.energy_type == "CONSUMED_ELECTRICAL_ENERGY" ] ) @@ -901,11 +918,9 @@ def heat_energy_generated(self) -> float: """ return sum( [ - v.data[-1].value + v.total_consumption_rounded for v in self.device_data_list - if len(v.data) - and v.data[-1].value - and v.energy_type == "HEAT_GENERATED" + if v.energy_type == "HEAT_GENERATED" ] ) diff --git a/custom_components/mypyllant/services.yaml b/custom_components/mypyllant/services.yaml index 329c1249..2aa8ead6 100644 --- a/custom_components/mypyllant/services.yaml +++ b/custom_components/mypyllant/services.yaml @@ -109,6 +109,60 @@ cancel_holiday: integration: mypyllant domain: climate +set_cooling_for_days: + name: Set Cooling for Days + description: Enables manual cooling between start and end dates + target: + entity: + integration: mypyllant + domain: climate + fields: + start: + name: Start Date + description: Optional start date of the cooling period, defaults to now (not supported on VRC700 controllers) + example: '"2023-01-10 14:00:00"' + selector: + datetime: + end: + name: End Date + description: Optional end date of the cooling period, defaults to one year in the future (not supported on VRC700 controllers) + example: '"2023-01-01 0:00:00"' + selector: + datetime: + duration_days: + name: Duration + description: Duration in days (only supported on VRC700 controllers) + example: 3 + selector: + number: + min: 0 + step: 0.1 + mode: box + +cancel_cooling_for_days: + name: Cancel Cooling for Days + description: Cancel manual cooling + target: + entity: + integration: mypyllant + domain: climate + +set_ventilation_boost: + name: Sets Ventilation Boost + description: Disables heating / cooling for 30min for a quick ventilation boost + target: + entity: + integration: mypyllant + domain: climate + +cancel_ventilation_boost: + name: Cancel Ventilation Boost + description: Cancels Ventilation Boost early + target: + entity: + integration: mypyllant + domain: climate + set_time_program: name: Set Time Program description: Updates the time program for a zone or room diff --git a/custom_components/mypyllant/switch.py b/custom_components/mypyllant/switch.py index c20ca2d2..d898714e 100644 --- a/custom_components/mypyllant/switch.py +++ b/custom_components/mypyllant/switch.py @@ -13,7 +13,11 @@ HolidayEntity, DomesticHotWaterCoordinatorEntity, EntityList, + ManualCoolingEntity, + ZoneCoordinatorEntity, + SystemCoordinatorEntity, ) +from myPyllant.enums import ZoneCurrentSpecialFunction from myPyllant.utils import get_default_holiday_dates _LOGGER = logging.getLogger(__name__) @@ -33,12 +37,22 @@ async def async_setup_entry( sensors: EntityList[SwitchEntity] = EntityList() for index, system in enumerate(coordinator.data): sensors.append(lambda: SystemHolidaySwitch(index, coordinator, config)) + if system.eebus: + sensors.append(lambda: SystemEebusSwitch(index, coordinator)) + if system.is_cooling_allowed: + sensors.append( + lambda: SystemManualCoolingSwitch(index, coordinator, config) + ) for dhw_index, dhw in enumerate(system.domestic_hot_water): sensors.append( lambda: DomesticHotWaterBoostSwitch(index, dhw_index, coordinator) ) - async_add_entities(sensors) + for zone_index, zone in enumerate(system.zones): + sensors.append( + lambda: ZoneVentilationBoostSwitch(index, zone_index, coordinator) + ) + async_add_entities(sensors) # type: ignore class SystemHolidaySwitch(HolidayEntity, SwitchEntity): @@ -76,6 +90,95 @@ def unique_id(self) -> str: return f"{DOMAIN}_{self.id_infix}_holiday_switch" +class SystemManualCoolingSwitch(ManualCoolingEntity, SwitchEntity): + _attr_icon = "mdi:snowflake-check" + + @property + def name(self): + return f"{self.name_prefix} Manual Cooling" + + @property + def is_on(self): + return self.system.manual_cooling_planned + + async def async_turn_on(self, **kwargs): + await self.coordinator.api.set_cooling_for_days( + self.system, duration_days=self.default_manual_cooling_duration + ) + await self.coordinator.async_request_refresh_delayed(20) + + async def async_turn_off(self, **kwargs): + await self.coordinator.api.cancel_cooling_for_days(self.system) + await self.coordinator.async_request_refresh_delayed(20) + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.id_infix}_manual_cooling_switch" + + +class SystemEebusSwitch(SystemCoordinatorEntity, SwitchEntity): + _attr_icon = "mdi:check-network" + + @property + def name(self): + return f"{self.name_prefix} EEBUS" + + @property + def available(self) -> bool: + return ( + self.system.eebus.get("spine_capable", False) + if self.system.eebus + else False + ) + + @property + def is_on(self): + return ( + self.system.eebus.get("spine_enabled", False) + if self.system.eebus + else False + ) + + async def async_turn_on(self, **kwargs): + await self.coordinator.api.toggle_eebus(self.system, enabled=True) + await self.coordinator.async_request_refresh_delayed() + + async def async_turn_off(self, **kwargs): + await self.coordinator.api.toggle_eebus(self.system, enabled=False) + await self.coordinator.async_request_refresh_delayed() + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.id_infix}_eebus_switch" + + +class ZoneVentilationBoostSwitch(ZoneCoordinatorEntity, SwitchEntity): + _attr_icon = "mdi:window-open-variant" + + @property + def name(self): + return f"{self.name_prefix} Ventilation Boost" + + @property + def is_on(self): + return ( + self.zone.current_special_function + == ZoneCurrentSpecialFunction.VENTILATION_BOOST + ) + + async def async_turn_on(self, **kwargs): + await self.coordinator.api.set_ventilation_boost(self.system) + await self.coordinator.async_request_refresh_delayed(20) + + async def async_turn_off(self, **kwargs): + await self.coordinator.api.cancel_ventilation_boost(self.system) + await self.coordinator.async_request_refresh_delayed(20) + + @property + def unique_id(self) -> str: + return f"{DOMAIN}_{self.id_infix}_ventilation_boost_switch" + + class DomesticHotWaterBoostSwitch(DomesticHotWaterCoordinatorEntity, SwitchEntity): @property def name(self): diff --git a/custom_components/mypyllant/translations/cs.json b/custom_components/mypyllant/translations/cs.json index 7cefafd9..04931bc7 100644 --- a/custom_components/mypyllant/translations/cs.json +++ b/custom_components/mypyllant/translations/cs.json @@ -19,6 +19,22 @@ "unknown": "Neočekávaná chyba" } }, + "entity": { + "climate": { + "mypyllant_zone": { + "state_attributes": { + "preset_mode": { + "state": { + "system_off": "Vypnuto", + "ventilation_boost": "Ventilační Boost", + "boost": "Boost", + "away": "Mimo domov" + } + } + } + } + } + }, "options": { "step": { "init": { @@ -30,11 +46,14 @@ "holiday_duration": "Nastavená doba trvání dovolené ve dnech", "time_program_overwrite": "Regulátory teploty přepisují časový program namísto nastavení rychlého veta", "default_holiday_setpoint": "Výchozí teplota pro dovolenou", + "manual_cooling_duration": "Doba trvání manuálního chlazení v dnech", "country": "Země", "brand": "Značka", "fetch_rts": "Načíst statistiky v reálném čase (není podporováno na každém systému)", "fetch_mpc": "Načíst využití energie v reálném čase (není podporováno na každém systému)", - "fetch_ambisense_rooms": "Načíst termostaty místnosti Ambisense" + "fetch_ambisense_rooms": "Načíst termostaty místnosti Ambisense", + "fetch_energy_management": "Načíst energetické údaje", + "fetch_eebus": "Načíst informace o EEBUS" } } } diff --git a/custom_components/mypyllant/translations/de.json b/custom_components/mypyllant/translations/de.json index efdc6bd3..a04e95f1 100644 --- a/custom_components/mypyllant/translations/de.json +++ b/custom_components/mypyllant/translations/de.json @@ -19,6 +19,22 @@ "unknown": "Unerwarteter Fehler, siehe Logs" } }, + "entity": { + "climate": { + "mypyllant_zone": { + "state_attributes": { + "preset_mode": { + "state": { + "system_off": "System aus", + "ventilation_boost": "Stoßlüften", + "boost": "Quick Veto", + "away": "Abwesenheitsmodus" + } + } + } + } + } + }, "options": { "step": { "init": { @@ -30,11 +46,14 @@ "holiday_duration": "Standard Dauer in Tagen für Abwesenheitsmodus", "time_program_overwrite": "Temperaturänderungen in der Zeitsteuerung speichern, statt Quick Veto", "default_holiday_setpoint": "Standardtemperatur während Abwesenheitsmodus", + "manual_cooling_duration": "Standard Dauer in Tagen für manuelle Kühlung", "country": "Land", "brand": "Hersteller", "fetch_rts": "Echtzeit-Statistiken abrufen (nicht auf jedem System unterstützt)", "fetch_mpc": "Echtzeit-Stromverbrauch abrufen (nicht auf jedem System unterstützt)", - "fetch_ambisense_rooms": "Ambisense Raumthermostate abrufen" + "fetch_ambisense_rooms": "Ambisense Raumthermostate abrufen", + "fetch_energy_management": "Energiemanagement-Daten abrufen", + "fetch_eebus": "EEBUS-Daten abrufen" } } } diff --git a/custom_components/mypyllant/translations/en.json b/custom_components/mypyllant/translations/en.json index 3237b014..a73db9a8 100644 --- a/custom_components/mypyllant/translations/en.json +++ b/custom_components/mypyllant/translations/en.json @@ -19,6 +19,22 @@ "unknown": "Unexpected error" } }, + "entity": { + "climate": { + "mypyllant_zone": { + "state_attributes": { + "preset_mode": { + "state": { + "system_off": "System Off", + "ventilation_boost": "Ventilation Boost", + "boost": "Quick Veto", + "away": "Holiday" + } + } + } + } + } + }, "options": { "step": { "init": { @@ -30,11 +46,14 @@ "holiday_duration": "Default duration in days for away mode", "time_program_overwrite": "Temperature controls overwrite time program instead of setting quick veto", "default_holiday_setpoint": "Default temperature setpoint for away mode", + "manual_cooling_duration": "Default duration for manual cooling in days", "country": "Country", "brand": "Brand", "fetch_rts": "Fetch real-time statistics (not supported on every system)", "fetch_mpc": "Fetch real-time power usage (not supported on every system)", - "fetch_ambisense_rooms": "Fetch Ambisense Room Thermostats" + "fetch_ambisense_rooms": "Fetch Ambisense Room Thermostats", + "fetch_energy_management": "Fetch Energy Management Data", + "fetch_eebus": "Fetch EEBUS Data" } } } diff --git a/custom_components/mypyllant/translations/it.json b/custom_components/mypyllant/translations/it.json index 4cba246c..fe22744d 100644 --- a/custom_components/mypyllant/translations/it.json +++ b/custom_components/mypyllant/translations/it.json @@ -19,6 +19,22 @@ "unknown": "Errore inaspettato" } }, + "entity": { + "climate": { + "mypyllant_zone": { + "state_attributes": { + "preset_mode": { + "state": { + "system_off": "Spento", + "ventilation_boost": "Ventilazione Boost", + "boost": "Boost", + "away": "Via" + } + } + } + } + } + }, "options": { "step": { "init": { @@ -30,11 +46,14 @@ "holiday_duration": "Durata di default in giorni per le vacanze", "time_program_overwrite": "Controlla la temperatura sovrascrivendo la programmazione oraria invece di attivare acqua calda rapida", "default_holiday_setpoint": "Temperatura di default per le vacanze", + "manual_cooling_duration": "Durata di default in giorni per il raffreddamento manuale", "country": "Stato", "brand": "Marca", "fetch_rts": "Recupera le statistiche in tempo reale (non supportato su tutti i sistemi)", "fetch_mpc": "Recupera l'uso della potenza in tempo reale (non supportato su tutti i sistemi)", - "fetch_ambisense_rooms": "Recupera i termostati ambiente Ambisense" + "fetch_ambisense_rooms": "Recupera i termostati ambiente Ambisense", + "fetch_energy_management": "Recupera i dati di gestione energetica", + "fetch_eebus": "Recupera i dati EEBUS" } } } diff --git a/custom_components/mypyllant/translations/pl.json b/custom_components/mypyllant/translations/pl.json index f04a1a4a..146fa555 100644 --- a/custom_components/mypyllant/translations/pl.json +++ b/custom_components/mypyllant/translations/pl.json @@ -19,6 +19,22 @@ "unknown": "Nieoczekiwany błąd" } }, + "entity": { + "climate": { + "mypyllant_zone": { + "state_attributes": { + "preset_mode": { + "state": { + "system_off": "Wyłączony", + "ventilation_boost": "Wentylacja", + "boost": "Podgrzewanie", + "away": "Poza domem" + } + } + } + } + } + }, "options": { "step": { "init": { @@ -30,11 +46,14 @@ "holiday_duration": "Domyślny czas trwania wakacji w dniach", "time_program_overwrite": "Kontrola temperatury nadpisuje program czasowy zamiast ustawiać szybkie weto", "default_holiday_setpoint": "Domyślna temperatura wakacyjna", + "manual_cooling_duration": "Domyślny czas trwania ręcznego chłodzenia w dniach", "country": "Kraj", "brand": "Marka", "fetch_rts": "Pobierz statystyki w czasie rzeczywistym (nie jest obsługiwane na każdym systemie)", "fetch_mpc": "Pobierz rzeczywiste zużycie energii (nie jest obsługiwane na każdym systemie)", - "fetch_ambisense_rooms": "Pobierz termostaty pokojowe Ambisense" + "fetch_ambisense_rooms": "Pobierz termostaty pokojowe Ambisense", + "fetch_energy_management": "Pobierz zarządzanie energią", + "fetch_eebus": "Pobierz informacje o EEBus" } } } diff --git a/custom_components/mypyllant/translations/sk.json b/custom_components/mypyllant/translations/sk.json index b52d21c4..b1bdaa7e 100644 --- a/custom_components/mypyllant/translations/sk.json +++ b/custom_components/mypyllant/translations/sk.json @@ -19,6 +19,22 @@ "unknown": "Neočakávaná chyba" } }, + "entity": { + "climate": { + "mypyllant_zone": { + "state_attributes": { + "preset_mode": { + "state": { + "system_off": "Vypnuté", + "ventilation_boost": "Ventilačný Boost", + "boost": "Boost", + "away": "Preč" + } + } + } + } + } + }, "options": { "step": { "init": { @@ -30,11 +46,14 @@ "holiday_duration": "Predvolená doba trvania dovolenky v dňoch", "time_program_overwrite": "Regulátory teploty prepisujú časový program namiesto nastavenia rýchleho veta", "default_holiday_setpoint": "Predvolená teplota počas dovolenky", + "manual_cooling_duration": "Predvolená doba trvania manuálneho chladenia v dňoch", "country": "Krajina", "brand": "Značka", "fetch_rts": "Načítať štatistiky v reálnom čase (nie je podporované na každom systéme)", "fetch_mpc": "Načítať využitie energie v reálnom čase (nie je podporované na každom systéme)", - "fetch_ambisense_rooms": "Načítať termostaty miestnosti Ambisense" + "fetch_ambisense_rooms": "Načítať termostaty miestnosti Ambisense", + "fetch_energy_management": "Načítať energetické údaje", + "fetch_eebus": "Načítať údaje z EEBUS" } } } diff --git a/custom_components/mypyllant/utils.py b/custom_components/mypyllant/utils.py index 28d7c487..758227ef 100644 --- a/custom_components/mypyllant/utils.py +++ b/custom_components/mypyllant/utils.py @@ -11,7 +11,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from custom_components.mypyllant.const import DOMAIN, OPTION_DEFAULT_HOLIDAY_DURATION +from custom_components.mypyllant.const import ( + DOMAIN, + OPTION_DEFAULT_HOLIDAY_DURATION, + OPTION_DEFAULT_MANUAL_COOLING_DURATION, + DEFAULT_MANUAL_COOLING_DURATION, +) from myPyllant.const import DEFAULT_HOLIDAY_DURATION if typing.TYPE_CHECKING: @@ -152,6 +157,47 @@ def holiday_remaining(self) -> timedelta | None: return self.zone.general.holiday_remaining if self.zone else None +class ManualCoolingEntity(SystemCoordinatorEntity): + def __init__( + self, + index: int, + coordinator: "SystemCoordinator", + config: ConfigEntry, + ) -> None: + super().__init__(index, coordinator) + self.config = config + + @property + def default_manual_cooling_duration(self): + return self.config.options.get( + OPTION_DEFAULT_MANUAL_COOLING_DURATION, DEFAULT_MANUAL_COOLING_DURATION + ) + + @property + def extra_state_attributes(self) -> typing.Mapping[str, typing.Any] | None: + return { + "manual_cooling_ongoing": self.system.manual_cooling_ongoing, + "manual_cooling_remaining_seconds": self.system.manual_cooling_remaining.total_seconds() + if self.system.manual_cooling_remaining + else None, + "manual_cooling_start_date_time": self.manual_cooling_start, + "manual_cooling_end_date_time": self.manual_cooling_end, + "manual_cooling_days_remaining": self.system.manual_cooling_days, + } + + @property + def manual_cooling_start(self) -> datetime | None: + return self.system.manual_cooling_start_date + + @property + def manual_cooling_end(self) -> datetime | None: + return self.system.manual_cooling_end_date + + @property + def manual_cooling_remaining(self) -> timedelta | None: + return self.system.manual_cooling_remaining + + def shorten_zone_name(zone_name: str) -> str: if zone_name.startswith("Zone "): return zone_name[5:] @@ -256,8 +302,8 @@ def device_info(self): ) @property - def available(self) -> bool | None: - return self.zone.is_active + def available(self) -> bool: + return bool(self.zone.is_active) class AmbisenseCoordinatorEntity(CoordinatorEntity): diff --git a/custom_components/mypyllant/ventilation_climate.py b/custom_components/mypyllant/ventilation_climate.py index 9e009439..bf810e93 100644 --- a/custom_components/mypyllant/ventilation_climate.py +++ b/custom_components/mypyllant/ventilation_climate.py @@ -41,7 +41,7 @@ class VentilationClimate(CoordinatorEntity, ClimateEntity): """ coordinator: SystemCoordinator - _attr_hvac_modes = [str(k) for k in VENTILATION_HVAC_MODE_MAP.keys()] + _attr_hvac_modes = list(VENTILATION_HVAC_MODE_MAP.keys()) _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__( diff --git a/custom_components/mypyllant/water_heater.py b/custom_components/mypyllant/water_heater.py index cf889696..4b5e8b1a 100644 --- a/custom_components/mypyllant/water_heater.py +++ b/custom_components/mypyllant/water_heater.py @@ -53,9 +53,20 @@ async def async_setup_entry( for index, system in enumerate(coordinator.data): for dhw_index, dhw in enumerate(system.domestic_hot_water): - dhws.append(lambda: DomesticHotWaterEntity(index, dhw_index, coordinator)) + data_key = f"dhw_{index}_{dhw_index}" + if data_key not in hass.data[DOMAIN][config.entry_id]: + hass.data[DOMAIN][config.entry_id][data_key] = {} + dhws.append( + lambda: DomesticHotWaterEntity( + index, + dhw_index, + coordinator, + hass.data[DOMAIN][config.entry_id][data_key], + ) + ) + + async_add_entities(dhws) # type: ignore - async_add_entities(dhws) if len(dhws) > 0: platform = entity_platform.async_get_current_platform() _LOGGER.debug("Setting up water heater entity services for %s", platform) @@ -79,11 +90,33 @@ class DomesticHotWaterEntity(CoordinatorEntity, WaterHeaterEntity): coordinator: SystemCoordinator _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, system_index, dhw_index, coordinator) -> None: + def __init__(self, system_index, dhw_index, coordinator, data) -> None: """Initialize entity.""" super().__init__(coordinator) self.system_index = system_index self.dhw_index = dhw_index + self.data = data + self.data["last_active_operation_mode"] = ( + self.current_operation + if self.current_operation != DHWOperationMode.OFF + else DHWOperationMode.TIME_CONTROLLED + ) + _LOGGER.debug( + "Saving last active DHW operation %s", + self.data["last_active_operation_mode"], + ) + + async def async_update(self) -> None: + """ + Save last active HVAC mode after update, so it can be restored in turn_on + """ + await super().async_update() + + if self.enabled and self.current_operation != DHWOperationMode.OFF: + _LOGGER.debug( + "Saving last active DHW operation mode %s", self.current_operation + ) + self.data["last_active_operation_mode"] = self.current_operation @property def operation_list(self): @@ -173,6 +206,12 @@ def current_operation(self) -> str: return str(DHWCurrentSpecialFunction.CYLINDER_BOOST.display_value) return str(self.domestic_hot_water.operation_mode_dhw.display_value) + async def async_turn_on(self, **kwargs: Any) -> None: + await self.async_set_operation_mode(self.data["last_active_operation_mode"]) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.async_set_operation_mode(DHWOperationMode.OFF) + async def async_set_temperature(self, **kwargs: Any) -> None: target_temp = kwargs.get(ATTR_TEMPERATURE) if isinstance(target_temp, (int, float)):