From c1dc11b7834be2022c6cd38fd2c362d82e72640c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Seux?= Date: Sat, 29 Oct 2022 19:11:01 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Expose=20climate=20components=20for?= =?UTF-8?q?=20zone=201=20and=20zone=202=20heating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This also allow to introduce better management of operating mode and zone state with nice enums. ⚠ Detection of heating mode (compensation curve or direct) does work but does not succeed to update the climate component accordingly. Default is set to direct mode at the moment. Fixes #25 Change-Id: I9104290bd7c81ac253170969f1d34933da3a3ebb --- custom_components/aquarea/climate.py | 287 ++++++++++++++++++++--- custom_components/aquarea/definitions.py | 104 ++++++-- 2 files changed, 340 insertions(+), 51 deletions(-) diff --git a/custom_components/aquarea/climate.py b/custom_components/aquarea/climate.py index aae3717..f8729dd 100644 --- a/custom_components/aquarea/climate.py +++ b/custom_components/aquarea/climate.py @@ -1,6 +1,8 @@ """Support for HeishaMon controlled heatpumps through MQTT.""" from __future__ import annotations import logging +from dataclasses import dataclass +from enum import Enum, Flag, auto from homeassistant.components import mqtt from homeassistant.components.mqtt.client import async_publish @@ -17,13 +19,44 @@ PRESET_COMFORT, PRESET_NONE, ) -from .definitions import lookup_by_value +from .definitions import lookup_by_value, OperatingMode from . import build_device_info from .const import DeviceType _LOGGER = logging.getLogger(__name__) +class ZoneState(Flag): + ZONE1 = auto() + ZONE2 = auto() + + @staticmethod + def from_id(id: int) -> ZoneState: + if id == 1: + return ZoneState.ZONE1 + elif id == 2: + return ZoneState.ZONE2 + else: + raise Exception(f"No zone with id {id}") + + def to_mqtt(self) -> str: + return str( + { + ZoneState.ZONE1: 0, + ZoneState.ZONE2: 1, + (ZoneState.ZONE1 | ZoneState.ZONE2): 2, + }[self] + ) + + @staticmethod + def from_mqtt(value: str) -> ZoneState: + return { + 0: ZoneState.ZONE1, + 1: ZoneState.ZONE2, + 2: (ZoneState.ZONE1 | ZoneState.ZONE2), + }[int(value)] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -35,6 +68,19 @@ async def async_setup_entry( name="Aquarea Domestic Water Heater", ) async_add_entities([HeishaMonDHWClimate(hass, description, config_entry)]) + description_zone1 = ZoneClimateEntityDescription( + key="panasonic_heat_pump/main/Z1_Temp", + name="Aquarea Zone 1 climate", + zone_id=1, + ) + zone1_climate = HeishaMonZoneClimate(hass, description_zone1, config_entry) + description_zone2 = ZoneClimateEntityDescription( + name="Aquarea Zone 2 climate", + key="panasonic_heat_pump/main/Z2_Temp", + zone_id=2, + ) + zone2_climate = HeishaMonZoneClimate(hass, description_zone2, config_entry) + async_add_entities([zone1_climate, zone2_climate]) class HeishaMonDHWClimate(ClimateEntity): @@ -68,7 +114,7 @@ def __init__( self._attr_hvac_mode = HVACMode.OFF self._attr_min_temp = 45 self._attr_max_temp = 65 - self._operating_mode = -1 + self._operating_mode = OperatingMode(0) # i.e None self._attr_preset_modes = [PRESET_ECO, PRESET_COMFORT] self._attr_preset_mode = PRESET_ECO @@ -126,9 +172,8 @@ def target_temperature_message_received(message): @callback def operating_state_message_received(message): - value = int(message.payload) - self._operating_mode = value - if value in [3, 4, 5, 6, 8]: + self._operating_mode = OperatingMode.from_mqtt(message.payload) + if OperatingMode.DHW in self._operating_mode: self._attr_hvac_mode = HVACMode.HEAT else: self._attr_hvac_mode = HVACMode.OFF @@ -143,33 +188,16 @@ def operating_state_message_received(message): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.HEAT: - value = { - "0": "4", - "1": "5", - "2": "6", - "3": "3", - "4": "4", - "5": "5", - "6": "6", - "7": "8", - "8": "8", - }[str(self._operating_mode)] + value = self._operating_mode | OperatingMode.DHW elif hvac_mode == HVACMode.OFF: - value = { - "0": "0", - "1": "1", - "2": "2", - "3": "3", # we don't have a way to completely shut down DHW, so it should be different than 3 - "4": "1", - "5": "2", - "6": "2", - "7": "7", - "8": "7", - }[str(self._operating_mode)] - if value == 3: + value = self._operating_mode & ~OperatingMode.DHW + if value == OperatingMode(0): # i.e "none" _LOGGER.warn( f"Impossible to set {hvac_mode} on this heatpump, we can't disable water heater when heating/cooling is already disabled" ) + raise NotImplemented( + f"Powering off heatpump entirely has not been implemented by this entity" + ) else: raise NotImplemented( f"Mode {hvac_mode} has not been implemented by this entity" @@ -177,11 +205,212 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: await async_publish( self.hass, "panasonic_heat_pump/commands/SetOperationMode", - value, + value.to_mqtt(), + 0, + False, + "utf-8", + ) + self._attr_hvac_mode = hvac_mode # let's be optimistic + self.async_write_ha_state() + + @property + def device_info(self): + return build_device_info(DeviceType.HEATPUMP) + + +@dataclass +class ZoneClimateEntityDescription(ClimateEntityDescription): + zone_id: int = 1 + + +class ZoneClimateMode(Enum): + COMPENSATION = 1 + DIRECT = 2 + + +class HeishaMonZoneClimate(ClimateEntity): + """Representation of a HeishaMon climate entity that is updated via MQTT.""" + + def __init__( + self, + hass: HomeAssistant, + description: ZoneClimateEntityDescription, + config_entry: ConfigEntry, + ) -> None: + """Initialize the climate entity.""" + self.config_entry_entry_id = config_entry.entry_id + self.entity_description = description + self.hass = hass + + self.zone_id = description.zone_id + slug = slugify(self.entity_description.key.replace("/", "_")) + self.entity_id = f"climate.{slug}" + self._attr_unique_id = f"{config_entry.entry_id}-{self.zone_id}" + + self._mode = ZoneClimateMode.COMPENSATION + self.change_mode(ZoneClimateMode.COMPENSATION) + + self._attr_temperature_unit = "°C" + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + self._attr_hvac_mode = HVACMode.OFF + + self._attr_min_temp = -5 + self._attr_max_temp = 5 + self._zone_state = ZoneState(0) # i.e None + self._operating_mode = OperatingMode(0) # i.e None + + def change_mode(self, mode: ZoneClimateMode): + _LOGGER.info(f"Changing mode to {mode} for zone {self.zone_id}") + self._mode = mode + if mode == ZoneClimateMode.COMPENSATION: + self._attr_min_temp = -5 + self._attr_max_temp = 5 + self._attr_target_temperature_step = 1 + else: + self._attr_min_temp = 15 + self._attr_max_temp = 25 + self._attr_target_temperature_step = 1 + + async def async_set_temperature(self, **kwargs) -> None: + temperature = kwargs.get("temperature") + + if self._mode == ZoneClimateMode.COMPENSATION: + _LOGGER.debug( + f"Changing {self.name} temperature offset to {temperature} for zone {self.zone_id}" + ) + elif self._mode == ZoneClimateMode.DIRECT: + _LOGGER.debug( + f"Changing {self.name} target temperature to {temperature} for zone {self.zone_id}" + ) + else: + raise Exception(f"Unknown climate mode: {self._mode}") + payload = str(temperature) + + _LOGGER.debug( + f"sending {payload} as temperature command for zone {self.zone_id}" + ) + await async_publish( + self.hass, + f"panasonic_heat_pump/commands/SetZ{self.zone_id}HeatRequestTemperature", + payload, 0, False, "utf-8", ) + + async def async_added_to_hass(self) -> None: + """Subscribe to MQTT events.""" + + @callback + def mode_received(message): + if message.payload == "0": + mode = ZoneClimateMode.COMPENSATION + elif message.payload == "1": + mode = ZoneClimateMode.DIRECT + else: + assert False, f"Mode received is not a known value" + if mode != self._mode: + self.change_mode(mode) + + await mqtt.async_subscribe( + self.hass, + f"panasonic_heat_pump/main/Heating_mode", + mode_received, + 1, + ) + + @callback + def current_temperature_message_received(message): + self._attr_current_temperature = float(message.payload) + self.async_write_ha_state() + + await mqtt.async_subscribe( + self.hass, + f"panasonic_heat_pump/main/Z{self.zone_id}_Temp", + current_temperature_message_received, + 1, + ) + + @callback + def target_temperature_message_received(message): + self._attr_target_temperature = float(message.payload) + self.async_write_ha_state() + + await mqtt.async_subscribe( + self.hass, + f"panasonic_heat_pump/main/main/Z{self.zone_id}_Heat_Request_Temp", + target_temperature_message_received, + 1, + ) + + def guess_hvac_mode() -> HVACMode: + global_heating = OperatingMode.HEAT in self._operating_mode + zone_heating = ZoneState.from_id(self.zone_id) in self._zone_state + if global_heating and zone_heating: + return HVACMode.HEAT + else: + return HVACMode.OFF + + @callback + def heating_conf_message_received(message): + if message.topic == "panasonic_heat_pump/main/Zones_State": + self._zone_state = ZoneState.from_mqtt(message.payload) + elif message.topic == "panasonic_heat_pump/main/Operating_Mode_State": + self._operating_mode = OperatingMode.from_mqtt(message.payload) + self._attr_hvac_mode = guess_hvac_mode() + self.async_write_ha_state() + + await mqtt.async_subscribe( + self.hass, + "panasonic_heat_pump/main/Zones_State", + heating_conf_message_received, + 1, + ) + await mqtt.async_subscribe( + self.hass, + "panasonic_heat_pump/main/Operating_Mode_State", + heating_conf_message_received, + 1, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.HEAT: + new_zone_state = self._zone_state | ZoneState.from_id(self.zone_id) + new_operating_mode = self._operating_mode | OperatingMode.HEAT + elif hvac_mode == HVACMode.OFF: + new_zone_state = self._zone_state & ~ZoneState.from_id(self.zone_id) + new_operating_mode = self._operating_mode + if new_zone_state == ZoneState(0): + new_operating_mode = self._operating_mode & ~OperatingMode.HEAT + else: + raise NotImplemented( + f"Mode {hvac_mode} has not been implemented by this entity" + ) + if new_operating_mode != self._operating_mode: + _LOGGER.debug( + f"Setting operation mode {new_operating_mode} for zone {self.zone_id}" + ) + await async_publish( + self.hass, + "panasonic_heat_pump/commands/SetOperationMode", + new_operating_mode.to_mqtt(), + 0, + False, + "utf-8", + ) + if new_zone_state not in [self._zone_state, ZoneState(0)]: + _LOGGER.debug( + f"Setting operation mode {new_zone_state} for zone {self.zone_id}" + ) + await async_publish( + self.hass, + "panasonic_heat_pump/commands/SetZones", + new_zone_state.to_mqtt(), + 0, + False, + "utf-8", + ) self._attr_hvac_mode = hvac_mode # let's be optimistic self.async_write_ha_state() diff --git a/custom_components/aquarea/definitions.py b/custom_components/aquarea/definitions.py index aeebab1..e54e4f8 100644 --- a/custom_components/aquarea/definitions.py +++ b/custom_components/aquarea/definitions.py @@ -2,10 +2,11 @@ from __future__ import annotations from functools import partial import json +from enum import Flag, auto from collections.abc import Callable from dataclasses import dataclass -from typing import Optional +from typing import Optional, TypeVar import logging from homeassistant.helpers.entity import EntityCategory @@ -32,25 +33,80 @@ _LOGGER = logging.getLogger(__name__) -OPERATING_MODE_TO_STRING = { - "0": "Heat only", - # "1": "Cool only", - "2": "Auto(Heat)", - "3": "DHW only", - "4": "Heat+DWH", - # "5": "Cool+DHW", - "6": "Auto(Heat)+DHW", - # "7": "Auto(Cool)", - # "8": "Auto(Cool)+DHW", -} - - -def operating_mode_to_state(value): - return lookup_by_value(OPERATING_MODE_TO_STRING, value) - -def read_operating_mode_state(value): - return OPERATING_MODE_TO_STRING.get(value, f"Unknown operating mode value: {value}") +class OperatingMode(Flag): + HEAT = auto() + COOL = auto() + DHW = auto() + AUTO = auto() + + @staticmethod + def modes_to_str(): + return { + OperatingMode.HEAT: "Heat only", + OperatingMode.COOL: "Cool only", + (OperatingMode.HEAT | OperatingMode.AUTO): "Auto(Heat)", + OperatingMode.DHW: "DHW only", + (OperatingMode.HEAT | OperatingMode.DHW): "Heat+DWH", + (OperatingMode.COOL | OperatingMode.DHW): "Cool+DHW", + ( + OperatingMode.HEAT | OperatingMode.AUTO | OperatingMode.DHW + ): "Auto(Heat)+DHW", + (OperatingMode.COOL | OperatingMode.AUTO): "Auto(Cool)", + ( + OperatingMode.COOL | OperatingMode.AUTO | OperatingMode.DHW + ): "Auto(Cool)+DHW", + } + + def __str__(self) -> str: + return self.modes_to_str().get(self, f"Unknown mode") + + @staticmethod + def modes_to_int(): + return { + OperatingMode.HEAT: 0, + OperatingMode.COOL: 1, + (OperatingMode.HEAT | OperatingMode.AUTO): 2, + OperatingMode.DHW: 3, + (OperatingMode.HEAT | OperatingMode.DHW): 4, + (OperatingMode.COOL | OperatingMode.DHW): 5, + (OperatingMode.HEAT | OperatingMode.AUTO | OperatingMode.DHW): 6, + (OperatingMode.COOL | OperatingMode.AUTO): 7, + (OperatingMode.COOL | OperatingMode.AUTO | OperatingMode.DHW): 8, + } + + def __int__(self) -> int: + return self.modes_to_int()[self] + + @staticmethod + def from_str(str_repr: str) -> OperatingMode: + operating_mode = lookup_by_value(OperatingMode.modes_to_str(), str_repr) + if operating_mode is None: + raise Exception( + f"Unable to find the operating mode corresponding to {str_repr}" + ) + return operating_mode + + @staticmethod + def from_mqtt(value: str) -> OperatingMode: + operating_mode = lookup_by_value(OperatingMode.modes_to_int(), int(value)) + if operating_mode is None: + raise Exception( + f"Unable to find the operating mode corresponding to {value}" + ) + return operating_mode + + def to_mqtt(self) -> str: + return str(int(self)) + + +def operating_mode_to_state(str_repr: str): + return str(int(OperatingMode.from_str(str_repr))) + + +def read_operating_mode_state(value: str) -> str: + mode = OperatingMode.from_mqtt(value) + return str(mode) ZONE_STATES_STRING = { @@ -79,8 +135,12 @@ def set_power_mode_time(value: str): return lookup_by_value(POWERFUL_MODE_TIMES, value) -def lookup_by_value(hash: dict[str, str], value: str) -> Optional[str]: - options = [key for (key, string) in hash.items() if string == value] +Key = TypeVar("Key") +Value = TypeVar("Value") + + +def lookup_by_value(hash: dict[Key, Value], value: Value) -> Optional[Key]: + options = [key for (key, v) in hash.items() if v == value] if len(options) == 0: return None return options[0] @@ -387,7 +447,7 @@ def guess_shift_or_direct_and_clamp_min_max_values( name="Aquarea Mode", state=read_operating_mode_state, state_to_mqtt=operating_mode_to_state, - options=list(OPERATING_MODE_TO_STRING.values()), + options=list(OperatingMode.modes_to_str().values()), ), HeishaMonSelectEntityDescription( heishamon_topic_id="SET17", # also TOP94