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

Initial xiaomi_ble integration #75618

Merged
merged 15 commits into from
Jul 22, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/xbox_live/ @MartinHjelmare
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
/tests/components/xiaomi_aqara/ @danielhiversen @syssi
/homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79
/tests/components/xiaomi_ble/ @Jc2k @Ernst79
/homeassistant/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG @bieniu
/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG @bieniu
/homeassistant/components/xiaomi_tv/ @simse
Expand Down
56 changes: 56 additions & 0 deletions homeassistant/components/xiaomi_ble/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""The Xiaomi Bluetooth integration."""
from __future__ import annotations

import logging

from xiaomi_ble import XiaomiBluetoothDeviceData

from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo

from .const import DOMAIN
from .sensor import sensor_update_to_bluetooth_data_update

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

_LOGGER = logging.getLogger(__name__)


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

data = XiaomiBluetoothDeviceData()

@callback
def _async_update_data(
service_info: BluetoothServiceInfo,
) -> PassiveBluetoothDataUpdate:
"""Update data from Xiaomi Bluetooth."""
return sensor_update_to_bluetooth_data_update(data.update(service_info))

hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = PassiveBluetoothDataUpdateCoordinator(
hass,
_LOGGER,
update_method=_async_update_data,
address=address,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
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
93 changes: 93 additions & 0 deletions homeassistant/components/xiaomi_ble/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Config flow for Xiaomi Bluetooth integration."""
from __future__ import annotations

from typing import Any

import voluptuous as vol
from xiaomi_ble import XiaomiBluetoothDeviceData as DeviceData

from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
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 DOMAIN


class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Xiaomi Bluetooth."""

VERSION = 1

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

async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
) -> FlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
device = DeviceData()
if not device.supported(discovery_info):
return self.async_abort(reason="not_supported")
self._discovery_info = discovery_info
self._discovered_device = device
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._discovered_device is not None
device = self._discovered_device
assert self._discovery_info is not None
discovery_info = self._discovery_info
title = device.title or device.get_device_name() or discovery_info.name
if user_input is not None:
return self.async_create_entry(title=title, data={})

self._set_confirm_only()
placeholders = {"name": title}
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)
return self.async_create_entry(
title=self._discovered_devices[address], data={}
)

current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
continue
device = DeviceData()
if device.supported(discovery_info):
self._discovered_devices[address] = (
device.title or device.get_device_name() or 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)}
),
)
3 changes: 3 additions & 0 deletions homeassistant/components/xiaomi_ble/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Xiaomi Bluetooth integration."""

DOMAIN = "xiaomi_ble"
15 changes: 15 additions & 0 deletions homeassistant/components/xiaomi_ble/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"domain": "xiaomi_ble",
"name": "Xiaomi BLE",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"bluetooth": [
{
"service_uuid": "0000fe95-0000-1000-8000-00805f9b34fb"
}
],
"requirements": ["xiaomi-ble==0.0.3"],
"dependencies": ["bluetooth"],
"codeowners": ["@Jc2k", "@Ernst79"],
"iot_class": "local_push"
}
149 changes: 149 additions & 0 deletions homeassistant/components/xiaomi_ble/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Support for xiaomi ble sensors."""
from __future__ import annotations

from typing import Optional, Union

from xiaomi_ble import DeviceClass, DeviceKey, SensorDeviceInfo, SensorUpdate, Units

from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
PassiveBluetoothDataUpdate,
PassiveBluetoothDataUpdateCoordinator,
PassiveBluetoothEntityKey,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NAME,
PERCENTAGE,
PRESSURE_MBAR,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
TEMP_CELSIUS,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import DOMAIN

SENSOR_DESCRIPTIONS = {
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume the integration now picks up all sensors that send temperature, humidity, pressure and battery. This might add all sensors, as almost all of them have battery messages. Does this mean that these sensors are added with only a battery entity? Not a problem, I guess, but we can also decide to comment out the sensors that aren't fully supported yet in the pypi package.

Copy link
Member Author

Choose a reason for hiding this comment

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

Pressure isnt used yet, but yes. I've left all the parsers active and able to capture fields that aren't turned into entities so we can do a second pass and capture them.

The can ignore any devices they don't want to add in the UI, so i'd slightly prefer to leave as is I think.

Copy link
Contributor

Choose a reason for hiding this comment

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

I’m just a bit afraid of all the issues that are going to be created, when people notice that their device only shows a battery entity. There is also one device that has added a counter in the battery data. Like every tenth advertisement, it will send a counter (as battery data) that is increased with one. I had some filter in BLE monitor for that (not in the parser, but in sensor.py. It will look it up, which one it was…)

Copy link
Member Author

Choose a reason for hiding this comment

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

Eh, I'm less worried about that. It's been that way with homekit_controller for a few years.

We can add some boilerplate to the docs about what device classes are support and/or a whitelist of devices we expect to work.

(I actually have a dashboard just for batteries, so I'm totally up for battery only sensors).

Copy link
Contributor

@Ernst79 Ernst79 Jul 22, 2022

Choose a reason for hiding this comment

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

The CGPR1 is the device with the odd battery sensor. I made a workaround in BLE monitor by storing the last 5 readings in a list, and only process the battery reading if it is in the list (only for the CGPR1, the others work fine without the workaround)

https://github.com/custom-components/ble_monitor/blob/de09d4396f2d9ce8f4d44c10c6ef90ebf6483c90/custom_components/ble_monitor/sensor.py#L259

The counter is actually counting downwards, this is what you get without the workaround.

image

Copy link
Member Author

Choose a reason for hiding this comment

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

I've moved this into Bluetooth-Devices/xiaomi-ble#3 and will handle it seperately to this PR.

(DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription(
key=f"{DeviceClass.TEMPERATURE}_{Units.TEMP_CELSIUS}",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=TEMP_CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
(DeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription(
key=f"{DeviceClass.HUMIDITY}_{Units.PERCENTAGE}",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
(DeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription(
key=f"{DeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=PRESSURE_MBAR,
state_class=SensorStateClass.MEASUREMENT,
),
(DeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription(
key=f"{DeviceClass.BATTERY}_{Units.PERCENTAGE}",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
(
DeviceClass.SIGNAL_STRENGTH,
Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
): SensorEntityDescription(
key=f"{DeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
}


def _device_key_to_bluetooth_entity_key(
device_key: DeviceKey,
) -> PassiveBluetoothEntityKey:
"""Convert a device key to an entity key."""
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)


def _sensor_device_info_to_hass(
sensor_device_info: SensorDeviceInfo,
) -> DeviceInfo:
"""Convert a sensor device info to a sensor device info."""
hass_device_info = DeviceInfo({})
if sensor_device_info.name is not None:
hass_device_info[ATTR_NAME] = sensor_device_info.name
if sensor_device_info.manufacturer is not None:
hass_device_info[ATTR_MANUFACTURER] = sensor_device_info.manufacturer
if sensor_device_info.model is not None:
hass_device_info[ATTR_MODEL] = sensor_device_info.model
return hass_device_info


def sensor_update_to_bluetooth_data_update(
sensor_update: SensorUpdate,
) -> PassiveBluetoothDataUpdate:
"""Convert a sensor update to a bluetooth data update."""
return PassiveBluetoothDataUpdate(
devices={
device_id: _sensor_device_info_to_hass(device_info)
for device_id, device_info in sensor_update.devices.items()
},
entity_descriptions={
_device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
(description.device_class, description.native_unit_of_measurement)
]
for device_key, description in sensor_update.entity_descriptions.items()
if description.device_class and description.native_unit_of_measurement
},
entity_data={
_device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
for device_key, sensor_values in sensor_update.entity_values.items()
},
entity_names={
_device_key_to_bluetooth_entity_key(device_key): sensor_values.name
for device_key, sensor_values in sensor_update.entity_values.items()
},
)


async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Xiaomi BLE sensors."""
coordinator: PassiveBluetoothDataUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
]
entry.async_on_unload(
coordinator.async_add_entities_listener(
XiaomiBluetoothSensorEntity, async_add_entities
)
)


class XiaomiBluetoothSensorEntity(
PassiveBluetoothCoordinatorEntity[
PassiveBluetoothDataUpdateCoordinator[Optional[Union[float, int]]]
],
SensorEntity,
):
"""Representation of a xiaomi ble sensor."""

@property
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.coordinator.entity_data.get(self.entity_key)
21 changes: 21 additions & 0 deletions homeassistant/components/xiaomi_ble/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"config": {
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
"step": {
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:component::bluetooth::config::step::user::data::address%]"
}
},
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
}
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
21 changes: 21 additions & 0 deletions homeassistant/components/xiaomi_ble/translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured",
"already_in_progress": "Configuration flow is already in progress",
"no_devices_found": "No devices found on the network"
},
"flow_title": "{name}",
"step": {
"bluetooth_confirm": {
"description": "Do you want to setup {name}?"
},
"user": {
"data": {
"address": "Device"
},
"description": "Choose a device to setup"
}
}
}
}
4 changes: 4 additions & 0 deletions homeassistant/generated/bluetooth.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,9 @@
{
"domain": "switchbot",
"service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"
},
{
"domain": "xiaomi_ble",
"service_uuid": "0000fe95-0000-1000-8000-00805f9b34fb"
}
]
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@
"ws66i",
"xbox",
"xiaomi_aqara",
"xiaomi_ble",
"xiaomi_miio",
"yale_smart_alarm",
"yamaha_musiccast",
Expand Down
Loading