From 86d410d863c10c629923fa303b85c866215a8268 Mon Sep 17 00:00:00 2001 From: David Straub Date: Tue, 5 May 2020 11:26:14 +0200 Subject: [PATCH] Add Home Connect integration (#29214) --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/home_connect/__init__.py | 106 +++++ homeassistant/components/home_connect/api.py | 372 ++++++++++++++++++ .../components/home_connect/binary_sensor.py | 65 +++ .../components/home_connect/config_flow.py | 23 ++ .../components/home_connect/const.py | 16 + .../components/home_connect/entity.py | 67 ++++ .../components/home_connect/manifest.json | 9 + .../components/home_connect/sensor.py | 92 +++++ .../components/home_connect/strings.json | 15 + .../components/home_connect/switch.py | 158 ++++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/home_connect/__init__.py | 1 + .../home_connect/test_config_flow.py | 54 +++ 17 files changed, 987 insertions(+) create mode 100644 homeassistant/components/home_connect/__init__.py create mode 100644 homeassistant/components/home_connect/api.py create mode 100644 homeassistant/components/home_connect/binary_sensor.py create mode 100644 homeassistant/components/home_connect/config_flow.py create mode 100644 homeassistant/components/home_connect/const.py create mode 100644 homeassistant/components/home_connect/entity.py create mode 100644 homeassistant/components/home_connect/manifest.json create mode 100644 homeassistant/components/home_connect/sensor.py create mode 100644 homeassistant/components/home_connect/strings.json create mode 100644 homeassistant/components/home_connect/switch.py create mode 100644 tests/components/home_connect/__init__.py create mode 100644 tests/components/home_connect/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2a66d2f2560..251fe05c014 100644 --- a/.coveragerc +++ b/.coveragerc @@ -299,6 +299,7 @@ omit = homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/* homeassistant/components/hlk_sw16/* + homeassistant/components/home_connect/* homeassistant/components/homematic/* homeassistant/components/homematic/climate.py homeassistant/components/homematic/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index bf194005959..a1c1b57e096 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -163,6 +163,7 @@ homeassistant/components/hikvisioncam/* @fbradyirl homeassistant/components/hisense_aehw4a1/* @bannhead homeassistant/components/history/* @home-assistant/core homeassistant/components/hive/* @Rendili @KJonline +homeassistant/components/home_connect/* @DavidMStraub homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @bdraco homeassistant/components/homekit_controller/* @Jc2k diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py new file mode 100644 index 00000000000..4e575963577 --- /dev/null +++ b/homeassistant/components/home_connect/__init__.py @@ -0,0 +1,106 @@ +"""Support for BSH Home Connect appliances.""" + +import asyncio +from datetime import timedelta +import logging + +from requests import HTTPError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.util import Throttle + +from . import api, config_flow +from .const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=1) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["binary_sensor", "sensor", "switch"] + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up Home Connect component.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + config_flow.OAuth2FlowHandler.async_register_implementation( + hass, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ), + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Home Connect from a config entry.""" + implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + + hc_api = api.ConfigEntryAuth(hass, entry, implementation) + + hass.data[DOMAIN][entry.entry_id] = hc_api + + await update_all_devices(hass, entry) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +@Throttle(SCAN_INTERVAL) +async def update_all_devices(hass, entry): + """Update all the devices.""" + data = hass.data[DOMAIN] + hc_api = data[entry.entry_id] + try: + await hass.async_add_executor_job(hc_api.get_devices) + for device_dict in hc_api.devices: + await hass.async_add_executor_job(device_dict["device"].initialize) + except HTTPError as err: + _LOGGER.warning("Cannot update devices: %s", err.response.status_code) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py new file mode 100644 index 00000000000..a208f9c7f0f --- /dev/null +++ b/homeassistant/components/home_connect/api.py @@ -0,0 +1,372 @@ +"""API for Home Connect bound to HASS OAuth.""" + +from asyncio import run_coroutine_threadsafe +import logging + +import homeconnect +from homeconnect.api import HomeConnectError + +from homeassistant import config_entries, core +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, TIME_SECONDS, UNIT_PERCENTAGE +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import ( + BSH_ACTIVE_PROGRAM, + BSH_POWER_OFF, + BSH_POWER_STANDBY, + SIGNAL_UPDATE_ENTITIES, +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigEntryAuth(homeconnect.HomeConnectAPI): + """Provide Home Connect authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + ): + """Initialize Home Connect Auth.""" + self.hass = hass + self.config_entry = config_entry + self.session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + super().__init__(self.session.token) + self.devices = [] + + def refresh_tokens(self) -> dict: + """Refresh and return new Home Connect tokens using Home Assistant OAuth2 session.""" + run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self.hass.loop + ).result() + + return self.session.token + + def get_devices(self): + """Get a dictionary of devices.""" + appl = self.get_appliances() + devices = [] + for app in appl: + if app.type == "Dryer": + device = Dryer(self.hass, app) + elif app.type == "Washer": + device = Washer(self.hass, app) + elif app.type == "Dishwasher": + device = Dishwasher(self.hass, app) + elif app.type == "FridgeFreezer": + device = FridgeFreezer(self.hass, app) + elif app.type == "Oven": + device = Oven(self.hass, app) + elif app.type == "CoffeeMaker": + device = CoffeeMaker(self.hass, app) + elif app.type == "Hood": + device = Hood(self.hass, app) + elif app.type == "Hob": + device = Hob(self.hass, app) + else: + _LOGGER.warning("Appliance type %s not implemented.", app.type) + continue + devices.append({"device": device, "entities": device.get_entity_info()}) + self.devices = devices + return devices + + +class HomeConnectDevice: + """Generic Home Connect device.""" + + # for some devices, this is instead BSH_POWER_STANDBY + # see https://developer.home-connect.com/docs/settings/power_state + power_off_state = BSH_POWER_OFF + + def __init__(self, hass, appliance): + """Initialize the device class.""" + self.hass = hass + self.appliance = appliance + + def initialize(self): + """Fetch the info needed to initialize the device.""" + try: + self.appliance.get_status() + except (HomeConnectError, ValueError): + _LOGGER.debug("Unable to fetch appliance status. Probably offline.") + try: + self.appliance.get_settings() + except (HomeConnectError, ValueError): + _LOGGER.debug("Unable to fetch settings. Probably offline.") + try: + program_active = self.appliance.get_programs_active() + except (HomeConnectError, ValueError): + _LOGGER.debug("Unable to fetch active programs. Probably offline.") + program_active = None + if program_active and "key" in program_active: + self.appliance.status[BSH_ACTIVE_PROGRAM] = {"value": program_active["key"]} + self.appliance.listen_events(callback=self.event_callback) + + def event_callback(self, appliance): + """Handle event.""" + _LOGGER.debug("Update triggered on %s", appliance.name) + _LOGGER.debug(self.appliance.status) + dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId) + + +class DeviceWithPrograms(HomeConnectDevice): + """Device with programs.""" + + PROGRAMS = [] + + def get_programs_available(self): + """Get the available programs.""" + return self.PROGRAMS + + def get_program_switches(self): + """Get a dictionary with info about program switches. + + There will be one switch for each program. + """ + programs = self.get_programs_available() + return [{"device": self, "program_name": p["name"]} for p in programs] + + def get_program_sensors(self): + """Get a dictionary with info about program sensors. + + There will be one of the four types of sensors for each + device. + """ + sensors = { + "Remaining Program Time": (None, None, DEVICE_CLASS_TIMESTAMP, 1), + "Duration": (TIME_SECONDS, "mdi:update", None, 1), + "Program Progress": (UNIT_PERCENTAGE, "mdi:progress-clock", None, 1), + } + return [ + { + "device": self, + "desc": k, + "unit": unit, + "key": "BSH.Common.Option.{}".format(k.replace(" ", "")), + "icon": icon, + "device_class": device_class, + "sign": sign, + } + for k, (unit, icon, device_class, sign) in sensors.items() + ] + + +class DeviceWithDoor(HomeConnectDevice): + """Device that has a door sensor.""" + + def get_door_entity(self): + """Get a dictionary with info about the door binary sensor.""" + return { + "device": self, + "desc": "Door", + "device_class": "door", + } + + +class Dryer(DeviceWithDoor, DeviceWithPrograms): + """Dryer class.""" + + PROGRAMS = [ + {"name": "LaundryCare.Dryer.Program.Cotton"}, + {"name": "LaundryCare.Dryer.Program.Synthetic"}, + {"name": "LaundryCare.Dryer.Program.Mix"}, + {"name": "LaundryCare.Dryer.Program.Blankets"}, + {"name": "LaundryCare.Dryer.Program.BusinessShirts"}, + {"name": "LaundryCare.Dryer.Program.DownFeathers"}, + {"name": "LaundryCare.Dryer.Program.Hygiene"}, + {"name": "LaundryCare.Dryer.Program.Jeans"}, + {"name": "LaundryCare.Dryer.Program.Outdoor"}, + {"name": "LaundryCare.Dryer.Program.SyntheticRefresh"}, + {"name": "LaundryCare.Dryer.Program.Towels"}, + {"name": "LaundryCare.Dryer.Program.Delicates"}, + {"name": "LaundryCare.Dryer.Program.Super40"}, + {"name": "LaundryCare.Dryer.Program.Shirts15"}, + {"name": "LaundryCare.Dryer.Program.Pillow"}, + {"name": "LaundryCare.Dryer.Program.AntiShrink"}, + ] + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + door_entity = self.get_door_entity() + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return { + "binary_sensor": [door_entity], + "switch": program_switches, + "sensor": program_sensors, + } + + +class Dishwasher(DeviceWithDoor, DeviceWithPrograms): + """Dishwasher class.""" + + PROGRAMS = [ + {"name": "Dishcare.Dishwasher.Program.Auto1"}, + {"name": "Dishcare.Dishwasher.Program.Auto2"}, + {"name": "Dishcare.Dishwasher.Program.Auto3"}, + {"name": "Dishcare.Dishwasher.Program.Eco50"}, + {"name": "Dishcare.Dishwasher.Program.Quick45"}, + {"name": "Dishcare.Dishwasher.Program.Intensiv70"}, + {"name": "Dishcare.Dishwasher.Program.Normal65"}, + {"name": "Dishcare.Dishwasher.Program.Glas40"}, + {"name": "Dishcare.Dishwasher.Program.GlassCare"}, + {"name": "Dishcare.Dishwasher.Program.NightWash"}, + {"name": "Dishcare.Dishwasher.Program.Quick65"}, + {"name": "Dishcare.Dishwasher.Program.Normal45"}, + {"name": "Dishcare.Dishwasher.Program.Intensiv45"}, + {"name": "Dishcare.Dishwasher.Program.AutoHalfLoad"}, + {"name": "Dishcare.Dishwasher.Program.IntensivPower"}, + {"name": "Dishcare.Dishwasher.Program.MagicDaily"}, + {"name": "Dishcare.Dishwasher.Program.Super60"}, + {"name": "Dishcare.Dishwasher.Program.Kurz60"}, + {"name": "Dishcare.Dishwasher.Program.ExpressSparkle65"}, + {"name": "Dishcare.Dishwasher.Program.MachineCare"}, + {"name": "Dishcare.Dishwasher.Program.SteamFresh"}, + {"name": "Dishcare.Dishwasher.Program.MaximumCleaning"}, + ] + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + door_entity = self.get_door_entity() + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return { + "binary_sensor": [door_entity], + "switch": program_switches, + "sensor": program_sensors, + } + + +class Oven(DeviceWithDoor, DeviceWithPrograms): + """Oven class.""" + + PROGRAMS = [ + {"name": "Cooking.Oven.Program.HeatingMode.PreHeating"}, + {"name": "Cooking.Oven.Program.HeatingMode.HotAir"}, + {"name": "Cooking.Oven.Program.HeatingMode.TopBottomHeating"}, + {"name": "Cooking.Oven.Program.HeatingMode.PizzaSetting"}, + {"name": "Cooking.Oven.Program.Microwave.600Watt"}, + ] + + power_off_state = BSH_POWER_STANDBY + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + door_entity = self.get_door_entity() + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return { + "binary_sensor": [door_entity], + "switch": program_switches, + "sensor": program_sensors, + } + + +class Washer(DeviceWithDoor, DeviceWithPrograms): + """Washer class.""" + + PROGRAMS = [ + {"name": "LaundryCare.Washer.Program.Cotton"}, + {"name": "LaundryCare.Washer.Program.Cotton.CottonEco"}, + {"name": "LaundryCare.Washer.Program.EasyCare"}, + {"name": "LaundryCare.Washer.Program.Mix"}, + {"name": "LaundryCare.Washer.Program.DelicatesSilk"}, + {"name": "LaundryCare.Washer.Program.Wool"}, + {"name": "LaundryCare.Washer.Program.Sensitive"}, + {"name": "LaundryCare.Washer.Program.Auto30"}, + {"name": "LaundryCare.Washer.Program.Auto40"}, + {"name": "LaundryCare.Washer.Program.Auto60"}, + {"name": "LaundryCare.Washer.Program.Chiffon"}, + {"name": "LaundryCare.Washer.Program.Curtains"}, + {"name": "LaundryCare.Washer.Program.DarkWash"}, + {"name": "LaundryCare.Washer.Program.Dessous"}, + {"name": "LaundryCare.Washer.Program.Monsoon"}, + {"name": "LaundryCare.Washer.Program.Outdoor"}, + {"name": "LaundryCare.Washer.Program.PlushToy"}, + {"name": "LaundryCare.Washer.Program.ShirtsBlouses"}, + {"name": "LaundryCare.Washer.Program.SportFitness"}, + {"name": "LaundryCare.Washer.Program.Towels"}, + {"name": "LaundryCare.Washer.Program.WaterProof"}, + ] + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + door_entity = self.get_door_entity() + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return { + "binary_sensor": [door_entity], + "switch": program_switches, + "sensor": program_sensors, + } + + +class CoffeeMaker(DeviceWithPrograms): + """Coffee maker class.""" + + PROGRAMS = [ + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto"}, + {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado"}, + ] + + power_off_state = BSH_POWER_STANDBY + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return {"switch": program_switches, "sensor": program_sensors} + + +class Hood(DeviceWithPrograms): + """Hood class.""" + + PROGRAMS = [ + {"name": "Cooking.Common.Program.Hood.Automatic"}, + {"name": "Cooking.Common.Program.Hood.Venting"}, + {"name": "Cooking.Common.Program.Hood.DelayedShutOff"}, + ] + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return {"switch": program_switches, "sensor": program_sensors} + + +class FridgeFreezer(DeviceWithDoor): + """Fridge/Freezer class.""" + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + door_entity = self.get_door_entity() + return {"binary_sensor": [door_entity]} + + +class Hob(DeviceWithPrograms): + """Hob class.""" + + PROGRAMS = [{"name": "Cooking.Hob.Program.PowerLevelMode"}] + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return {"switch": program_switches, "sensor": program_sensors} diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py new file mode 100644 index 00000000000..4810231b432 --- /dev/null +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -0,0 +1,65 @@ +"""Provides a binary sensor for Home Connect.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity + +from .const import BSH_DOOR_STATE, DOMAIN +from .entity import HomeConnectEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Home Connect binary sensor.""" + + def get_entities(): + entities = [] + hc_api = hass.data[DOMAIN][config_entry.entry_id] + for device_dict in hc_api.devices: + entity_dicts = device_dict.get("entities", {}).get("binary_sensor", []) + entities += [HomeConnectBinarySensor(**d) for d in entity_dicts] + return entities + + async_add_entities(await hass.async_add_executor_job(get_entities), True) + + +class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): + """Binary sensor for Home Connect.""" + + def __init__(self, device, desc, device_class): + """Initialize the entity.""" + super().__init__(device, desc) + self._device_class = device_class + self._state = None + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return bool(self._state) + + @property + def available(self): + """Return true if the binary sensor is available.""" + return self._state is not None + + async def async_update(self): + """Update the binary sensor's status.""" + state = self.device.appliance.status.get(BSH_DOOR_STATE, {}) + if not state: + self._state = None + elif state.get("value") in [ + "BSH.Common.EnumType.DoorState.Closed", + "BSH.Common.EnumType.DoorState.Locked", + ]: + self._state = False + elif state.get("value") == "BSH.Common.EnumType.DoorState.Open": + self._state = True + else: + _LOGGER.warning("Unexpected value for HomeConnect door state: %s", state) + self._state = None + _LOGGER.debug("Updated, new state: %s", self._state) + + @property + def device_class(self): + """Return the device class.""" + return self._device_class diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py new file mode 100644 index 00000000000..4a714bac73f --- /dev/null +++ b/homeassistant/components/home_connect/config_flow.py @@ -0,0 +1,23 @@ +"""Config flow for Home Connect.""" +import logging + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Home Connect OAuth2 authentication.""" + + DOMAIN = DOMAIN + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py new file mode 100644 index 00000000000..10eb5dfd1e3 --- /dev/null +++ b/homeassistant/components/home_connect/const.py @@ -0,0 +1,16 @@ +"""Constants for the Home Connect integration.""" + +DOMAIN = "home_connect" + +OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize" +OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token" + +BSH_POWER_STATE = "BSH.Common.Setting.PowerState" +BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" +BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" +BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" +BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" +BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" +BSH_DOOR_STATE = "BSH.Common.Status.DoorState" + +SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py new file mode 100644 index 00000000000..12f86059023 --- /dev/null +++ b/homeassistant/components/home_connect/entity.py @@ -0,0 +1,67 @@ +"""Home Connect entity base class.""" + +import logging + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .api import HomeConnectDevice +from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES + +_LOGGER = logging.getLogger(__name__) + + +class HomeConnectEntity(Entity): + """Generic Home Connect entity (base class).""" + + def __init__(self, device: HomeConnectDevice, desc: str) -> None: + """Initialize the entity.""" + self.device = device + self.desc = desc + self._name = f"{self.device.appliance.name} {desc}" + + async def async_added_to_hass(self): + """Register callbacks.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITIES, self._update_callback + ) + ) + + @callback + def _update_callback(self, ha_id): + """Update data.""" + if ha_id == self.device.appliance.haId: + self.async_entity_update() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the node (used for Entity_ID).""" + return self._name + + @property + def unique_id(self): + """Return the unique id base on the id returned by Home Connect and the entity name.""" + return f"{self.device.appliance.haId}-{self.desc}" + + @property + def device_info(self): + """Return info about the device.""" + return { + "identifiers": {(DOMAIN, self.device.appliance.haId)}, + "name": self.device.appliance.name, + "manufacturer": self.device.appliance.brand, + "model": self.device.appliance.vib, + } + + @callback + def async_entity_update(self): + """Update the entity.""" + _LOGGER.debug("Entity update triggered on %s", self) + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json new file mode 100644 index 00000000000..5c330f760b0 --- /dev/null +++ b/homeassistant/components/home_connect/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "home_connect", + "name": "Home Connect", + "documentation": "https://www.home-assistant.io/integrations/home_connect", + "dependencies": ["http"], + "codeowners": ["@DavidMStraub"], + "requirements": ["homeconnect==0.5"], + "config_flow": true +} diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py new file mode 100644 index 00000000000..add1a0084b3 --- /dev/null +++ b/homeassistant/components/home_connect/sensor.py @@ -0,0 +1,92 @@ +"""Provides a sensor for Home Connect.""" + +from datetime import timedelta +import logging + +from homeassistant.const import DEVICE_CLASS_TIMESTAMP +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .entity import HomeConnectEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Home Connect sensor.""" + + def get_entities(): + """Get a list of entities.""" + entities = [] + hc_api = hass.data[DOMAIN][config_entry.entry_id] + for device_dict in hc_api.devices: + entity_dicts = device_dict.get("entities", {}).get("sensor", []) + entities += [HomeConnectSensor(**d) for d in entity_dicts] + return entities + + async_add_entities(await hass.async_add_executor_job(get_entities), True) + + +class HomeConnectSensor(HomeConnectEntity): + """Sensor class for Home Connect.""" + + def __init__(self, device, desc, key, unit, icon, device_class, sign=1): + """Initialize the entity.""" + super().__init__(device, desc) + self._state = None + self._key = key + self._unit = unit + self._icon = icon + self._device_class = device_class + self._sign = sign + + @property + def state(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def available(self): + """Return true if the sensor is available.""" + return self._state is not None + + async def async_update(self): + """Update the sensos status.""" + status = self.device.appliance.status + if self._key not in status: + self._state = None + else: + if self.device_class == DEVICE_CLASS_TIMESTAMP: + if "value" not in status[self._key]: + self._state = None + elif ( + self._state is not None + and self._sign == 1 + and self._state < dt_util.utcnow() + ): + # if the date is supposed to be in the future but we're + # already past it, set state to None. + self._state = None + else: + seconds = self._sign * float(status[self._key]["value"]) + self._state = ( + dt_util.utcnow() + timedelta(seconds=seconds) + ).isoformat() + else: + self._state = status[self._key].get("value") + _LOGGER.debug("Updated, new state: %s", self._state) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def device_class(self): + """Return the device class.""" + return self._device_class diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json new file mode 100644 index 00000000000..6125897c962 --- /dev/null +++ b/homeassistant/components/home_connect/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + }, + "abort": { + "missing_configuration": "The Home Connect component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Home Connect." + } + } +} diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py new file mode 100644 index 00000000000..c5fcdef25b7 --- /dev/null +++ b/homeassistant/components/home_connect/switch.py @@ -0,0 +1,158 @@ +"""Provides a switch for Home Connect.""" +import logging + +from homeconnect.api import HomeConnectError + +from homeassistant.components.switch import SwitchEntity + +from .const import ( + BSH_ACTIVE_PROGRAM, + BSH_OPERATION_STATE, + BSH_POWER_ON, + BSH_POWER_STATE, + DOMAIN, +) +from .entity import HomeConnectEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Home Connect switch.""" + + def get_entities(): + """Get a list of entities.""" + entities = [] + hc_api = hass.data[DOMAIN][config_entry.entry_id] + for device_dict in hc_api.devices: + entity_dicts = device_dict.get("entities", {}).get("switch", []) + entity_list = [HomeConnectProgramSwitch(**d) for d in entity_dicts] + entity_list += [HomeConnectPowerSwitch(device_dict["device"])] + entities += entity_list + return entities + + async_add_entities(await hass.async_add_executor_job(get_entities), True) + + +class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): + """Switch class for Home Connect.""" + + def __init__(self, device, program_name): + """Initialize the entity.""" + desc = " ".join(["Program", program_name.split(".")[-1]]) + super().__init__(device, desc) + self.program_name = program_name + self._state = None + self._remote_allowed = None + + @property + def is_on(self): + """Return true if the switch is on.""" + return bool(self._state) + + @property + def available(self): + """Return true if the entity is available.""" + return True + + async def async_turn_on(self, **kwargs): + """Start the program.""" + _LOGGER.debug("Tried to turn on program %s", self.program_name) + try: + await self.hass.async_add_executor_job( + self.device.appliance.start_program, self.program_name + ) + except HomeConnectError as err: + _LOGGER.error("Error while trying to start program: %s", err) + self.async_entity_update() + + async def async_turn_off(self, **kwargs): + """Stop the program.""" + _LOGGER.debug("Tried to stop program %s", self.program_name) + try: + await self.hass.async_add_executor_job(self.device.appliance.stop_program) + except HomeConnectError as err: + _LOGGER.error("Error while trying to stop program: %s", err) + self.async_entity_update() + + async def async_update(self): + """Update the switch's status.""" + state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) + if state.get("value") == self.program_name: + self._state = True + else: + self._state = False + _LOGGER.debug("Updated, new state: %s", self._state) + + +class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): + """Power switch class for Home Connect.""" + + def __init__(self, device): + """Inititialize the entity.""" + super().__init__(device, "Power") + self._state = None + + @property + def is_on(self): + """Return true if the switch is on.""" + return bool(self._state) + + async def async_turn_on(self, **kwargs): + """Switch the device on.""" + _LOGGER.debug("Tried to switch on %s", self.name) + try: + await self.hass.async_add_executor_job( + self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON, + ) + except HomeConnectError as err: + _LOGGER.error("Error while trying to turn on device: %s", err) + self._state = False + self.async_entity_update() + + async def async_turn_off(self, **kwargs): + """Switch the device off.""" + _LOGGER.debug("tried to switch off %s", self.name) + try: + await self.hass.async_add_executor_job( + self.device.appliance.set_setting, + BSH_POWER_STATE, + self.device.power_off_state, + ) + except HomeConnectError as err: + _LOGGER.error("Error while trying to turn off device: %s", err) + self._state = True + self.async_entity_update() + + async def async_update(self): + """Update the switch's status.""" + if ( + self.device.appliance.status.get(BSH_POWER_STATE, {}).get("value") + == BSH_POWER_ON + ): + self._state = True + elif ( + self.device.appliance.status.get(BSH_POWER_STATE, {}).get("value") + == self.device.power_off_state + ): + self._state = False + elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get( + "value", None + ) in [ + "BSH.Common.EnumType.OperationState.Ready", + "BSH.Common.EnumType.OperationState.DelayedStart", + "BSH.Common.EnumType.OperationState.Run", + "BSH.Common.EnumType.OperationState.Pause", + "BSH.Common.EnumType.OperationState.ActionRequired", + "BSH.Common.EnumType.OperationState.Aborting", + "BSH.Common.EnumType.OperationState.Finished", + ]: + self._state = True + elif ( + self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get("value") + == "BSH.Common.EnumType.OperationState.Inactive" + ): + self._state = False + else: + self._state = None + _LOGGER.debug("Updated, new state: %s", self._state) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 472f722538d..e40bcc7e1d5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -50,6 +50,7 @@ FLOWS = [ "harmony", "heos", "hisense_aehw4a1", + "home_connect", "homekit", "homekit_controller", "homematicip_cloud", diff --git a/requirements_all.txt b/requirements_all.txt index 84d70e8824d..d18f04d7b59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -721,6 +721,9 @@ home-assistant-frontend==20200427.2 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 +# homeassistant.components.home_connect +homeconnect==0.5 + # homeassistant.components.homematicip_cloud homematicip==0.10.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41eb968a8c2..6f158788e37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -302,6 +302,9 @@ home-assistant-frontend==20200427.2 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 +# homeassistant.components.home_connect +homeconnect==0.5 + # homeassistant.components.homematicip_cloud homematicip==0.10.17 diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py new file mode 100644 index 00000000000..2b61501c59a --- /dev/null +++ b/tests/components/home_connect/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Connect integration.""" diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py new file mode 100644 index 00000000000..be6c21fe0a7 --- /dev/null +++ b/tests/components/home_connect/test_config_flow.py @@ -0,0 +1,54 @@ +"""Test the Home Connect config flow.""" +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.home_connect.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.helpers import config_entry_oauth2_flow + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +async def test_full_flow(hass, aiohttp_client, aioclient_mock): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + "home_connect", + { + "home_connect": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, + "http": {"base_url": "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + "home_connect", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1