-
-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Add MELCloud integration #30712
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rytilahti
merged 35 commits into
home-assistant:dev
from
vilppuvuorinen:integration-melcloud
Feb 10, 2020
Merged
Add MELCloud integration #30712
Changes from all commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
670393c
Add MELCloud integration
bf1968a
Run isort
0ebcfa1
Fix pylint errors
9f3b2b0
Run black
859e44a
Increase coverage
a183ff2
Update pymelcloud dependency
f1a6446
Add HVAC_MODE_OFF emulation
60d80ad
Remove print
b7fe6d3
Update pymelcloud to enable device type filtering
59125f4
Collapse except blocks and chain ClientNotReadys
181d9b1
Add preliminary documentation URL
aae7999
Use list comp for creating model info
3535996
f-string galore
5df0ce2
Delegate fan mode mapping to pymelcloud
7b1f8fe
Fix type annotation
005b0cc
Access AtaDevice through self._device
cd89dab
Prefer list comprehension
42f00b5
Update pymelcloud to leverage device type grouping
7117a86
Remove DOMAIN presence check
edb9994
Fix async_setup_entry
d82ec65
Simplify empty model name check
c70be1f
Improve config validation
76ca68c
Remove unused manifest properties
a80b1ee
Remove redundant ClimateDevice property override
a65b780
Add __init__.py to coverage exclusion
fdcfcff
Use CONF_USERNAME instead of CONF_EMAIL
819cbf9
Use asyncio.gather instead of asyncio.wait
16ae770
Misc fixes
a00f9c6
Use _abort_if_unique_id_configured to update token
1acac8b
Fix them tests
9612ac4
Remove current state guards
ea9fa9c
Fix that gather call
1fcfe91
Implement sensor definitions without str manipulation
d9a56c2
Use relative intra-package imports
f95dd32
Update homeassistant/components/melcloud/config_flow.py
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| { | ||
| "config": { | ||
| "title": "MELCloud", | ||
| "step": { | ||
| "user": { | ||
| "title": "Connect to MELCloud", | ||
| "description": "Connect using your MELCloud account.", | ||
| "data": { | ||
| "username": "Email used to login to MELCloud.", | ||
| "password": "MELCloud password." | ||
| } | ||
| } | ||
| }, | ||
| "error": { | ||
| "cannot_connect": "Failed to connect, please try again", | ||
| "invalid_auth": "Invalid authentication", | ||
| "unknown": "Unexpected error" | ||
| }, | ||
| "abort": { | ||
| "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| """The MELCloud Climate integration.""" | ||
| import asyncio | ||
| from datetime import timedelta | ||
| import logging | ||
| from typing import Any, Dict, List | ||
|
|
||
| from aiohttp import ClientConnectionError | ||
| from async_timeout import timeout | ||
| from pymelcloud import Device, get_devices | ||
| import voluptuous as vol | ||
|
|
||
| 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.typing import HomeAssistantType | ||
| from homeassistant.util import Throttle | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) | ||
|
|
||
| PLATFORMS = ["climate", "sensor"] | ||
|
|
||
| CONF_LANGUAGE = "language" | ||
| CONFIG_SCHEMA = vol.Schema( | ||
| { | ||
| DOMAIN: vol.Schema( | ||
| { | ||
| vol.Required(CONF_USERNAME): cv.string, | ||
| vol.Required(CONF_TOKEN): cv.string, | ||
| } | ||
| ) | ||
| }, | ||
| extra=vol.ALLOW_EXTRA, | ||
| ) | ||
|
|
||
|
|
||
| async def async_setup(hass: HomeAssistantType, config: ConfigEntry): | ||
| """Establish connection with MELCloud.""" | ||
| if DOMAIN not in config: | ||
| return True | ||
|
vilppuvuorinen marked this conversation as resolved.
Outdated
|
||
|
|
||
| username = config[DOMAIN][CONF_USERNAME] | ||
| token = config[DOMAIN][CONF_TOKEN] | ||
| hass.async_create_task( | ||
| hass.config_entries.flow.async_init( | ||
| DOMAIN, | ||
| context={"source": SOURCE_IMPORT}, | ||
| data={CONF_USERNAME: username, CONF_TOKEN: token}, | ||
| ) | ||
| ) | ||
| return True | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): | ||
| """Establish connection with MELClooud.""" | ||
| conf = entry.data | ||
| mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) | ||
| hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) | ||
| for platform in PLATFORMS: | ||
| hass.async_create_task( | ||
| hass.config_entries.async_forward_entry_setup(entry, platform) | ||
| ) | ||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass, config_entry): | ||
| """Unload a config entry.""" | ||
| await asyncio.gather( | ||
| *[ | ||
| hass.config_entries.async_forward_entry_unload(config_entry, platform) | ||
| for platform in PLATFORMS | ||
| ] | ||
| ) | ||
| hass.data[DOMAIN].pop(config_entry.entry_id) | ||
| if not hass.data[DOMAIN]: | ||
| hass.data.pop(DOMAIN) | ||
| return True | ||
|
|
||
|
|
||
| class MelCloudDevice: | ||
| """MELCloud Device instance.""" | ||
|
|
||
| def __init__(self, device: Device): | ||
| """Construct a device wrapper.""" | ||
| self.device = device | ||
| self.name = device.name | ||
|
vilppuvuorinen marked this conversation as resolved.
Outdated
|
||
| self._available = True | ||
|
|
||
| @Throttle(MIN_TIME_BETWEEN_UPDATES) | ||
| async def async_update(self, **kwargs): | ||
| """Pull the latest data from MELCloud.""" | ||
| try: | ||
| await self.device.update() | ||
| self._available = True | ||
| except ClientConnectionError: | ||
| _LOGGER.warning("Connection failed for %s", self.name) | ||
| self._available = False | ||
|
|
||
| async def async_set(self, properties: Dict[str, Any]): | ||
| """Write state changes to the MELCloud API.""" | ||
| try: | ||
| await self.device.set(properties) | ||
| self._available = True | ||
| except ClientConnectionError: | ||
| _LOGGER.warning("Connection failed for %s", self.name) | ||
| self._available = False | ||
|
|
||
| @property | ||
| def available(self) -> bool: | ||
| """Return True if entity is available.""" | ||
| return self._available | ||
|
|
||
| @property | ||
| def device_id(self): | ||
| """Return device ID.""" | ||
| return self.device.device_id | ||
|
|
||
| @property | ||
| def building_id(self): | ||
| """Return building ID of the device.""" | ||
| return self.device.building_id | ||
|
|
||
| @property | ||
| def device_info(self): | ||
| """Return a device description for device registry.""" | ||
| _device_info = { | ||
| "identifiers": {(DOMAIN, f"{self.device.mac}-{self.device.serial}")}, | ||
| "manufacturer": "Mitsubishi Electric", | ||
| "name": self.name, | ||
| } | ||
| unit_infos = self.device.units | ||
| if unit_infos is not None: | ||
| _device_info["model"] = ", ".join( | ||
| [x["model"] for x in unit_infos if x["model"]] | ||
| ) | ||
| return _device_info | ||
|
|
||
|
|
||
| async def mel_devices_setup(hass, token) -> List[MelCloudDevice]: | ||
| """Query connected devices from MELCloud.""" | ||
| session = hass.helpers.aiohttp_client.async_get_clientsession() | ||
| try: | ||
| with timeout(10): | ||
| all_devices = await get_devices( | ||
| token, | ||
| session, | ||
| conf_update_interval=timedelta(minutes=5), | ||
| device_set_debounce=timedelta(seconds=1), | ||
| ) | ||
| except (asyncio.TimeoutError, ClientConnectionError) as ex: | ||
| raise ConfigEntryNotReady() from ex | ||
|
|
||
| wrapped_devices = {} | ||
| for device_type, devices in all_devices.items(): | ||
| wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] | ||
| return wrapped_devices | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| """Platform for climate integration.""" | ||
| from datetime import timedelta | ||
| import logging | ||
| from typing import List, Optional | ||
|
|
||
| from pymelcloud import DEVICE_TYPE_ATA | ||
|
|
||
| from homeassistant.components.climate import ClimateDevice | ||
| from homeassistant.components.climate.const import ( | ||
| DEFAULT_MAX_TEMP, | ||
| DEFAULT_MIN_TEMP, | ||
| HVAC_MODE_OFF, | ||
| SUPPORT_FAN_MODE, | ||
| SUPPORT_TARGET_TEMPERATURE, | ||
| ) | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import TEMP_CELSIUS | ||
| 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 | ||
|
|
||
| SCAN_INTERVAL = timedelta(seconds=60) | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| 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]], | ||
| True, | ||
| ) | ||
|
|
||
|
|
||
| class AtaDeviceClimate(ClimateDevice): | ||
| """Air-to-Air climate device.""" | ||
|
|
||
| def __init__(self, device: MelCloudDevice): | ||
| """Initialize the climate.""" | ||
| self._api = device | ||
| self._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() | ||
|
|
||
| @property | ||
| def device_info(self): | ||
| """Return a device description for device registry.""" | ||
| return self._api.device_info | ||
|
|
||
| @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._device.operation_mode | ||
| if not self._device.power or mode is None: | ||
| return HVAC_MODE_OFF | ||
| return HVAC_MODE_LOOKUP.get(mode) | ||
|
|
||
| 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 = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) | ||
| if operation_mode is None: | ||
| raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") | ||
|
|
||
| props = {"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 [HVAC_MODE_OFF] + [ | ||
| HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes | ||
| ] | ||
|
|
||
| @property | ||
| def current_temperature(self) -> Optional[float]: | ||
| """Return the current temperature.""" | ||
| return self._device.room_temperature | ||
|
|
||
| @property | ||
| def target_temperature(self) -> Optional[float]: | ||
| """Return the temperature we try to reach.""" | ||
| return self._device.target_temperature | ||
|
|
||
| async def async_set_temperature(self, **kwargs) -> None: | ||
| """Set new target temperature.""" | ||
| await self._device.set( | ||
| {"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.""" | ||
| return self._device.fan_speed | ||
|
|
||
| async def async_set_fan_mode(self, fan_mode: str) -> None: | ||
| """Set new target fan mode.""" | ||
| await self._device.set({"fan_speed": fan_mode}) | ||
|
|
||
| @property | ||
| def fan_modes(self) -> Optional[List[str]]: | ||
| """Return the list of available fan modes.""" | ||
| return self._device.fan_speeds | ||
|
|
||
| async def async_turn_on(self) -> None: | ||
| """Turn the entity on.""" | ||
| await self._device.set({"power": True}) | ||
|
|
||
| async def async_turn_off(self) -> None: | ||
| """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.""" | ||
| min_value = self._device.target_temperature_min | ||
| if min_value is not None: | ||
| return min_value | ||
|
|
||
| return convert_temperature( | ||
| DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit | ||
| ) | ||
|
|
||
| @property | ||
| def max_temp(self) -> float: | ||
| """Return the maximum temperature.""" | ||
| max_value = self._device.target_temperature_max | ||
| if max_value is not None: | ||
| return max_value | ||
|
|
||
| return convert_temperature( | ||
| DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.