From 86d410d863c10c629923fa303b85c866215a8268 Mon Sep 17 00:00:00 2001
From: David Straub <straub@protonmail.com>
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
-- 
GitLab