diff --git a/.coveragerc b/.coveragerc index 48701a8563a..89da763f3ca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -414,7 +414,9 @@ omit = homeassistant/components/mediaroom/media_player.py homeassistant/components/melcloud/__init__.py homeassistant/components/melcloud/climate.py + homeassistant/components/melcloud/const.py homeassistant/components/melcloud/sensor.py + homeassistant/components/melcloud/water_heater.py homeassistant/components/message_bird/notify.py homeassistant/components/met/weather.py homeassistant/components/meteo_france/__init__.py diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index ef932f36aa4..0e81d6101b3 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import Throttle @@ -22,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -PLATFORMS = ["climate", "sensor"] +PLATFORMS = ["climate", "sensor", "water_heater"] CONF_LANGUAGE = "language" CONFIG_SCHEMA = vol.Schema( @@ -128,6 +129,7 @@ class MelCloudDevice: def device_info(self): """Return a device description for device registry.""" _device_info = { + "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, "identifiers": {(DOMAIN, f"{self.device.mac}-{self.device.serial}")}, "manufacturer": "Mitsubishi Electric", "name": self.name, diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 95cb1489f45..c661b1a59ad 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -1,14 +1,26 @@ """Platform for climate integration.""" from datetime import timedelta import logging -from typing import List, Optional +from typing import Any, Dict, List, Optional -from pymelcloud import DEVICE_TYPE_ATA +from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice +import pymelcloud.ata_device as ata +import pymelcloud.atw_device as atw +from pymelcloud.atw_device import ( + PROPERTY_ZONE_1_OPERATION_MODE, + PROPERTY_ZONE_2_OPERATION_MODE, + Zone, +) from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, @@ -19,51 +31,90 @@ from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.temperature import convert as convert_temperature from . import MelCloudDevice -from .const import DOMAIN, HVAC_MODE_LOOKUP, HVAC_MODE_REVERSE_LOOKUP, TEMP_UNIT_LOOKUP +from .const import ATTR_STATUS, DOMAIN, TEMP_UNIT_LOOKUP SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) +ATA_HVAC_MODE_LOOKUP = { + ata.OPERATION_MODE_HEAT: HVAC_MODE_HEAT, + ata.OPERATION_MODE_DRY: HVAC_MODE_DRY, + ata.OPERATION_MODE_COOL: HVAC_MODE_COOL, + ata.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY, + ata.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, +} +ATA_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATA_HVAC_MODE_LOOKUP.items()} + + +ATW_ZONE_HVAC_MODE_LOOKUP = { + atw.ZONE_OPERATION_MODE_HEAT: HVAC_MODE_HEAT, + atw.ZONE_OPERATION_MODE_COOL: HVAC_MODE_COOL, +} +ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATW_ZONE_HVAC_MODE_LOOKUP.items()} + + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ): """Set up MelCloud device climate based on config_entry.""" mel_devices = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [AtaDeviceClimate(mel_device) for mel_device in mel_devices[DEVICE_TYPE_ATA]], + [ + AtaDeviceClimate(mel_device, mel_device.device) + for mel_device in mel_devices[DEVICE_TYPE_ATA] + ] + + [ + AtwDeviceZoneClimate(mel_device, mel_device.device, zone) + for mel_device in mel_devices[DEVICE_TYPE_ATW] + for zone in mel_device.device.zones + ], True, ) -class AtaDeviceClimate(ClimateDevice): - """Air-to-Air climate device.""" +class MelCloudClimate(ClimateDevice): + """Base climate device.""" def __init__(self, device: MelCloudDevice): """Initialize the climate.""" - self._api = device - self._device = self._api.device + self.api = device + self._base_device = self.api.device self._name = device.name - @property - def unique_id(self) -> Optional[str]: - """Return a unique ID.""" - return f"{self._device.serial}-{self._device.mac}" - - @property - def name(self): - """Return the display name of this light.""" - return self._name - async def async_update(self): """Update state from MELCloud.""" - await self._api.async_update() + await self.api.async_update() @property def device_info(self): """Return a device description for device registry.""" - return self._api.device_info + return self.api.device_info + + @property + def target_temperature_step(self) -> Optional[float]: + """Return the supported step of target temperature.""" + return self._base_device.temperature_increment + + +class AtaDeviceClimate(MelCloudClimate): + """Air-to-Air climate device.""" + + def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None: + """Initialize the climate.""" + super().__init__(device) + self._device = ata_device + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self.api.device.serial}-{self.api.device.mac}" + + @property + def name(self): + """Return the display name of this entity.""" + return self._name @property def temperature_unit(self) -> str: @@ -76,7 +127,7 @@ class AtaDeviceClimate(ClimateDevice): mode = self._device.operation_mode if not self._device.power or mode is None: return HVAC_MODE_OFF - return HVAC_MODE_LOOKUP.get(mode) + return ATA_HVAC_MODE_LOOKUP.get(mode) async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" @@ -84,7 +135,7 @@ class AtaDeviceClimate(ClimateDevice): await self._device.set({"power": False}) return - operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) + operation_mode = ATA_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) if operation_mode is None: raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") @@ -97,7 +148,7 @@ class AtaDeviceClimate(ClimateDevice): def hvac_modes(self) -> List[str]: """Return the list of available hvac operation modes.""" return [HVAC_MODE_OFF] + [ - HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes + ATA_HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes ] @property @@ -116,11 +167,6 @@ class AtaDeviceClimate(ClimateDevice): {"target_temperature": kwargs.get("temperature", self.target_temperature)} ) - @property - def target_temperature_step(self) -> Optional[float]: - """Return the supported step of target temperature.""" - return self._device.target_temperature_step - @property def fan_mode(self) -> Optional[str]: """Return the fan setting.""" @@ -135,6 +181,11 @@ class AtaDeviceClimate(ClimateDevice): """Return the list of available fan modes.""" return self._device.fan_speeds + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE + async def async_turn_on(self) -> None: """Turn the entity on.""" await self._device.set({"power": True}) @@ -143,11 +194,6 @@ class AtaDeviceClimate(ClimateDevice): """Turn the entity off.""" await self._device.set({"power": False}) - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE - @property def min_temp(self) -> float: """Return the minimum temperature.""" @@ -169,3 +215,108 @@ class AtaDeviceClimate(ClimateDevice): return convert_temperature( DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit ) + + +class AtwDeviceZoneClimate(MelCloudClimate): + """Air-to-Water zone climate device.""" + + def __init__( + self, device: MelCloudDevice, atw_device: AtwDevice, atw_zone: Zone + ) -> None: + """Initialize the climate.""" + super().__init__(device) + self._device = atw_device + self._zone = atw_zone + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self.api.device.serial}-{self._zone.zone_index}" + + @property + def name(self) -> str: + """Return the display name of this entity.""" + return f"{self._name} {self._zone.name}" + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the optional state attributes with device specific additions.""" + data = { + ATTR_STATUS: ATW_ZONE_HVAC_MODE_LOOKUP.get( + self._zone.status, self._zone.status + ) + } + return data + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + mode = self._zone.operation_mode + if not self._device.power or mode is None: + return HVAC_MODE_OFF + return ATW_ZONE_HVAC_MODE_LOOKUP.get(mode, HVAC_MODE_OFF) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._device.set({"power": False}) + return + + operation_mode = ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) + if operation_mode is None: + raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") + + if self._zone.zone_index == 1: + props = {PROPERTY_ZONE_1_OPERATION_MODE: operation_mode} + else: + props = {PROPERTY_ZONE_2_OPERATION_MODE: operation_mode} + if self.hvac_mode == HVAC_MODE_OFF: + props["power"] = True + await self._device.set(props) + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return [self.hvac_mode] + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._zone.room_temperature + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + return self._zone.target_temperature + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self._zone.set_target_temperature( + kwargs.get("temperature", self.target_temperature) + ) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def min_temp(self) -> float: + """Return the minimum temperature. + + MELCloud API does not expose radiator zone temperature limits. + """ + return convert_temperature(10, TEMP_CELSIUS, self.temperature_unit) + + @property + def max_temp(self) -> float: + """Return the maximum temperature. + + MELCloud API does not expose radiator zone temperature limits. + """ + return convert_temperature(30, TEMP_CELSIUS, self.temperature_unit) diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py index e262be2c3fb..c6ce4391294 100644 --- a/homeassistant/components/melcloud/const.py +++ b/homeassistant/components/melcloud/const.py @@ -1,26 +1,11 @@ """Constants for the MELCloud Climate integration.""" -import pymelcloud.ata_device as ata_device from pymelcloud.const import UNIT_TEMP_CELSIUS, UNIT_TEMP_FAHRENHEIT -from homeassistant.components.climate.const import ( - HVAC_MODE_COOL, - HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, -) from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT DOMAIN = "melcloud" -HVAC_MODE_LOOKUP = { - ata_device.OPERATION_MODE_HEAT: HVAC_MODE_HEAT, - ata_device.OPERATION_MODE_DRY: HVAC_MODE_DRY, - ata_device.OPERATION_MODE_COOL: HVAC_MODE_COOL, - ata_device.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY, - ata_device.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, -} -HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in HVAC_MODE_LOOKUP.items()} +ATTR_STATUS = "status" TEMP_UNIT_LOOKUP = { UNIT_TEMP_CELSIUS: TEMP_CELSIUS, diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 55edcdd0d9f..61fc9e1b730 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", - "requirements": ["pymelcloud==2.1.0"], + "requirements": ["pymelcloud==2.4.0"], "dependencies": [], "codeowners": ["@vilppuvuorinen"] } diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 8f55906443e..31bfd005ac1 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,7 +1,8 @@ """Support for MelCloud device sensors.""" import logging -from pymelcloud import DEVICE_TYPE_ATA +from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW +from pymelcloud.atw_device import Zone from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers.entity import Entity @@ -16,7 +17,7 @@ ATTR_DEVICE_CLASS = "device_class" ATTR_VALUE_FN = "value_fn" ATTR_ENABLED_FN = "enabled" -SENSORS = { +ATA_SENSORS = { "room_temperature": { ATTR_MEASUREMENT_NAME: "Room Temperature", ATTR_ICON: "mdi:thermometer", @@ -34,6 +35,34 @@ SENSORS = { ATTR_ENABLED_FN: lambda x: x.device.has_energy_consumed_meter, }, } +ATW_SENSORS = { + "outside_temperature": { + ATTR_MEASUREMENT_NAME: "Outside Temperature", + ATTR_ICON: "mdi:thermometer", + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_VALUE_FN: lambda x: x.device.outside_temperature, + ATTR_ENABLED_FN: lambda x: True, + }, + "tank_temperature": { + ATTR_MEASUREMENT_NAME: "Tank Temperature", + ATTR_ICON: "mdi:thermometer", + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_VALUE_FN: lambda x: x.device.tank_temperature, + ATTR_ENABLED_FN: lambda x: True, + }, +} +ATW_ZONE_SENSORS = { + "room_temperature": { + ATTR_MEASUREMENT_NAME: "Room Temperature", + ATTR_ICON: "mdi:thermometer", + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_VALUE_FN: lambda zone: zone.room_temperature, + ATTR_ENABLED_FN: lambda x: True, + } +} _LOGGER = logging.getLogger(__name__) @@ -43,22 +72,35 @@ async def async_setup_entry(hass, entry, async_add_entities): mel_devices = hass.data[DOMAIN].get(entry.entry_id) async_add_entities( [ - MelCloudSensor(mel_device, measurement, definition) - for measurement, definition in SENSORS.items() + MelDeviceSensor(mel_device, measurement, definition) + for measurement, definition in ATA_SENSORS.items() for mel_device in mel_devices[DEVICE_TYPE_ATA] if definition[ATTR_ENABLED_FN](mel_device) + ] + + [ + MelDeviceSensor(mel_device, measurement, definition) + for measurement, definition in ATW_SENSORS.items() + for mel_device in mel_devices[DEVICE_TYPE_ATW] + if definition[ATTR_ENABLED_FN](mel_device) + ] + + [ + AtwZoneSensor(mel_device, zone, measurement, definition) + for mel_device in mel_devices[DEVICE_TYPE_ATW] + for zone in mel_device.device.zones + for measurement, definition, in ATW_ZONE_SENSORS.items() + if definition[ATTR_ENABLED_FN](zone) ], True, ) -class MelCloudSensor(Entity): +class MelDeviceSensor(Entity): """Representation of a Sensor.""" - def __init__(self, device: MelCloudDevice, measurement, definition): + def __init__(self, api: MelCloudDevice, measurement, definition): """Initialize the sensor.""" - self._api = device - self._name_slug = device.name + self._api = api + self._name_slug = api.name self._measurement = measurement self._def = definition @@ -100,3 +142,20 @@ class MelCloudSensor(Entity): def device_info(self): """Return a device description for device registry.""" return self._api.device_info + + +class AtwZoneSensor(MelDeviceSensor): + """Air-to-Air device sensor.""" + + def __init__( + self, api: MelCloudDevice, zone: Zone, measurement, definition, + ): + """Initialize the sensor.""" + super().__init__(api, measurement, definition) + self._zone = zone + self._name_slug = f"{api.name} {zone.name}" + + @property + def state(self): + """Return zone based state.""" + return self._def[ATTR_VALUE_FN](self._zone) diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py new file mode 100644 index 00000000000..fa7aff2b640 --- /dev/null +++ b/homeassistant/components/melcloud/water_heater.py @@ -0,0 +1,132 @@ +"""Platform for water_heater integration.""" +from typing import List, Optional + +from pymelcloud import DEVICE_TYPE_ATW, AtwDevice +from pymelcloud.atw_device import ( + PROPERTY_OPERATION_MODE, + PROPERTY_TARGET_TANK_TEMPERATURE, +) +from pymelcloud.device import PROPERTY_POWER + +from homeassistant.components.water_heater import ( + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN, MelCloudDevice +from .const import ATTR_STATUS, TEMP_UNIT_LOOKUP + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +): + """Set up MelCloud device climate based on config_entry.""" + mel_devices = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + AtwWaterHeater(mel_device, mel_device.device) + for mel_device in mel_devices[DEVICE_TYPE_ATW] + ], + True, + ) + + +class AtwWaterHeater(WaterHeaterDevice): + """Air-to-Water water heater.""" + + def __init__(self, api: MelCloudDevice, device: AtwDevice) -> None: + """Initialize water heater device.""" + self._api = api + self._device = device + self._name = device.name + + async def async_update(self): + """Update state from MELCloud.""" + await self._api.async_update() + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self._api.device.serial}" + + @property + def name(self): + """Return the display name of this entity.""" + return self._name + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self._device.set({PROPERTY_POWER: True}) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self._device.set({PROPERTY_POWER: False}) + + @property + def device_state_attributes(self): + """Return the optional state attributes with device specific additions.""" + data = {ATTR_STATUS: self._device.status} + return data + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) + + @property + def current_operation(self) -> Optional[str]: + """Return current operation as reported by pymelcloud.""" + return self._device.operation_mode + + @property + def operation_list(self) -> List[str]: + """Return the list of available operation modes as reported by pymelcloud.""" + return self._device.operation_modes + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._device.tank_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._device.target_tank_temperature + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self._device.set( + { + PROPERTY_TARGET_TANK_TEMPERATURE: kwargs.get( + "temperature", self.target_temperature + ) + } + ) + + async def async_set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + await self._device.set({PROPERTY_OPERATION_MODE: operation_mode}) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE + + @property + def min_temp(self) -> Optional[float]: + """Return the minimum temperature.""" + return self._device.target_tank_temperature_min + + @property + def max_temp(self) -> Optional[float]: + """Return the maximum temperature.""" + return self._device.target_tank_temperature_max diff --git a/requirements_all.txt b/requirements_all.txt index 678bf3ba674..1146b6f16c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1372,7 +1372,7 @@ pymailgunner==1.4 pymediaroom==0.6.4 # homeassistant.components.melcloud -pymelcloud==2.1.0 +pymelcloud==2.4.0 # homeassistant.components.somfy pymfy==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00e04b8fd99..ac561926564 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -501,7 +501,7 @@ pylitejet==0.1 pymailgunner==1.4 # homeassistant.components.melcloud -pymelcloud==2.1.0 +pymelcloud==2.4.0 # homeassistant.components.somfy pymfy==0.7.1