Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
670393c
Add MELCloud integration
Jan 12, 2020
bf1968a
Run isort
Jan 12, 2020
0ebcfa1
Fix pylint errors
Jan 12, 2020
9f3b2b0
Run black
Jan 12, 2020
859e44a
Increase coverage
Jan 12, 2020
a183ff2
Update pymelcloud dependency
Jan 13, 2020
f1a6446
Add HVAC_MODE_OFF emulation
Jan 25, 2020
60d80ad
Remove print
Jan 25, 2020
b7fe6d3
Update pymelcloud to enable device type filtering
Jan 29, 2020
59125f4
Collapse except blocks and chain ClientNotReadys
Jan 29, 2020
181d9b1
Add preliminary documentation URL
Jan 29, 2020
aae7999
Use list comp for creating model info
Jan 30, 2020
3535996
f-string galore
Jan 30, 2020
5df0ce2
Delegate fan mode mapping to pymelcloud
Jan 30, 2020
7b1f8fe
Fix type annotation
Jan 30, 2020
005b0cc
Access AtaDevice through self._device
Jan 30, 2020
cd89dab
Prefer list comprehension
Jan 31, 2020
42f00b5
Update pymelcloud to leverage device type grouping
Feb 8, 2020
7117a86
Remove DOMAIN presence check
Feb 8, 2020
edb9994
Fix async_setup_entry
Feb 8, 2020
d82ec65
Simplify empty model name check
Feb 8, 2020
c70be1f
Improve config validation
Feb 9, 2020
76ca68c
Remove unused manifest properties
Feb 9, 2020
a80b1ee
Remove redundant ClimateDevice property override
Feb 9, 2020
a65b780
Add __init__.py to coverage exclusion
Feb 9, 2020
fdcfcff
Use CONF_USERNAME instead of CONF_EMAIL
Feb 9, 2020
819cbf9
Use asyncio.gather instead of asyncio.wait
Feb 9, 2020
16ae770
Misc fixes
Feb 9, 2020
a00f9c6
Use _abort_if_unique_id_configured to update token
Feb 9, 2020
1acac8b
Fix them tests
Feb 9, 2020
9612ac4
Remove current state guards
Feb 9, 2020
ea9fa9c
Fix that gather call
Feb 9, 2020
1fcfe91
Implement sensor definitions without str manipulation
Feb 9, 2020
d9a56c2
Use relative intra-package imports
Feb 9, 2020
f95dd32
Update homeassistant/components/melcloud/config_flow.py
Feb 10, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@ omit =
homeassistant/components/mcp23017/*
homeassistant/components/media_extractor/*
homeassistant/components/mediaroom/media_player.py
homeassistant/components/melcloud/__init__.py
homeassistant/components/melcloud/climate.py
Comment thread
vilppuvuorinen marked this conversation as resolved.
Outdated
homeassistant/components/melcloud/sensor.py
homeassistant/components/message_bird/notify.py
homeassistant/components/met/weather.py
homeassistant/components/meteo_france/__init__.py
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ homeassistant/components/mastodon/* @fabaff
homeassistant/components/matrix/* @tinloaf
homeassistant/components/mcp23017/* @jardiamj
homeassistant/components/mediaroom/* @dgomes
homeassistant/components/melcloud/* @vilppuvuorinen
homeassistant/components/melissa/* @kennedyshead
homeassistant/components/met/* @danielhiversen
homeassistant/components/meteo_france/* @victorcerutti @oncleben31 @Quentame
Expand Down
23 changes: 23 additions & 0 deletions homeassistant/components/melcloud/.translations/en.json
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."
}
}
}
160 changes: 160 additions & 0 deletions homeassistant/components/melcloud/__init__.py
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
Comment thread
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
Comment thread
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
171 changes: 171 additions & 0 deletions homeassistant/components/melcloud/climate.py
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
)
Loading