Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add EufyLife Bluetooth integration #85907

Merged
merged 16 commits into from
Jan 17, 2023
Merged
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ omit =
homeassistant/components/esphome/switch.py
homeassistant/components/etherscan/sensor.py
homeassistant/components/eufy/*
homeassistant/components/eufylife_ble/__init__.py
homeassistant/components/eufylife_ble/sensor.py
homeassistant/components/everlights/light.py
homeassistant/components/evohome/*
homeassistant/components/ezviz/__init__.py
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@ build.json @home-assistant/supervisor
/tests/components/escea/ @lazdavila
/homeassistant/components/esphome/ @OttoWinter @jesserockz
/tests/components/esphome/ @OttoWinter @jesserockz
/homeassistant/components/eufylife_ble/ @bdr99
/tests/components/eufylife_ble/ @bdr99
/homeassistant/components/evil_genius_labs/ @balloob
/tests/components/evil_genius_labs/ @balloob
/homeassistant/components/evohome/ @zxdavb
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/brands/eufy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"domain": "eufy",
"name": "eufy",
"integrations": ["eufy", "eufylife_ble"],
"iot_standards": []
}
70 changes: 70 additions & 0 deletions homeassistant/components/eufylife_ble/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""The EufyLife integration."""
from __future__ import annotations

from eufylife_ble_client import EufyLifeBLEDevice

from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback

from .const import CONF_MODEL, DOMAIN
from .models import EufyLifeData

PLATFORMS: list[Platform] = [Platform.SENSOR]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up EufyLife device from a config entry."""
address = entry.unique_id
assert address is not None

model = entry.data[CONF_MODEL]
client = EufyLifeBLEDevice(model=model)

@callback
def _async_update_ble(
service_info: bluetooth.BluetoothServiceInfoBleak,
change: bluetooth.BluetoothChange,
) -> None:
"""Update from a ble callback."""
client.set_ble_device_and_advertisement_data(
service_info.device, service_info.advertisement
)
if not client.advertisement_data_contains_state:
hass.async_create_task(client.connect())
bdraco marked this conversation as resolved.
Show resolved Hide resolved

entry.async_on_unload(
bluetooth.async_register_callback(
hass,
_async_update_ble,
BluetoothCallbackMatcher({ADDRESS: address}),
bluetooth.BluetoothScanningMode.ACTIVE,
)
)

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EufyLifeData(
address,
model,
client,
)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

async def _async_stop(event: Event) -> None:
"""Close the connection."""
await client.stop()

entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
99 changes: 99 additions & 0 deletions homeassistant/components/eufylife_ble/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Config flow for the EufyLife integration."""
from __future__ import annotations

from typing import Any

from eufylife_ble_client import MODEL_TO_NAME
import voluptuous as vol

from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import FlowResult

from .const import CONF_MODEL, DOMAIN


class EufyLifeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for EufyLife."""

VERSION = 1

def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, str] = {}

async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()

if discovery_info.name not in MODEL_TO_NAME:
return self.async_abort(reason="not_supported")

self._discovery_info = discovery_info
return await self.async_step_bluetooth_confirm()

async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
assert self._discovery_info is not None
discovery_info = self._discovery_info

model_name = MODEL_TO_NAME.get(discovery_info.name)
assert model_name is not None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we using dict.get on line 50 if we know that they key is not missing?


if user_input is not None:
return self.async_create_entry(
title=model_name, data={CONF_MODEL: discovery_info.name}
)

self._set_confirm_only()
placeholders = {"name": model_name}
self.context["title_placeholders"] = placeholders
return self.async_show_form(
step_id="bluetooth_confirm", description_placeholders=placeholders
)

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the user step to pick discovered device."""
if user_input is not None:
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()

model = self._discovered_devices[address]
return self.async_create_entry(
title=MODEL_TO_NAME[model],
data={CONF_MODEL: model},
)

current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if (
address in current_addresses
or address in self._discovered_devices
or discovery_info.name not in MODEL_TO_NAME
):
continue
self._discovered_devices[address] = discovery_info.name

if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
),
)
5 changes: 5 additions & 0 deletions homeassistant/components/eufylife_ble/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Constants for the EufyLife integration."""

DOMAIN = "eufylife_ble"

CONF_MODEL = "model"
28 changes: 28 additions & 0 deletions homeassistant/components/eufylife_ble/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"domain": "eufylife_ble",
"name": "EufyLife",
bdraco marked this conversation as resolved.
Show resolved Hide resolved
"integration_type": "device",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
"bluetooth": [
{
"local_name": "eufy T9140"
},
{
"local_name": "eufy T9146"
},
{
"local_name": "eufy T9147"
},
{
"local_name": "eufy T9148"
},
{
"local_name": "eufy T9149"
}
],
"requirements": ["eufylife_ble_client==0.1.7"],
"dependencies": ["bluetooth_adapters"],
"codeowners": ["@bdr99"],
"iot_class": "local_push"
}
15 changes: 15 additions & 0 deletions homeassistant/components/eufylife_ble/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""The EufyLife integration models."""
from __future__ import annotations

from dataclasses import dataclass

from eufylife_ble_client import EufyLifeBLEDevice


@dataclass
class EufyLifeData:
"""Data for the EufyLife integration."""

address: str
model: str
client: EufyLifeBLEDevice
Loading