Add Home Connect integration (#29214)
parent
6b9eed5a70
commit
86d410d863
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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}
|
|
@ -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
|
|
@ -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__)
|
|
@ -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"
|
|
@ -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)
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -50,6 +50,7 @@ FLOWS = [
|
|||
"harmony",
|
||||
"heos",
|
||||
"hisense_aehw4a1",
|
||||
"home_connect",
|
||||
"homekit",
|
||||
"homekit_controller",
|
||||
"homematicip_cloud",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the Home Connect integration."""
|
|
@ -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
|
Loading…
Reference in New Issue