mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 09:47:52 +00:00
Add Medcom Bluetooth integration (#100289)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
60dd5f1d50
commit
4028596977
@ -726,6 +726,8 @@ omit =
|
|||||||
homeassistant/components/matter/__init__.py
|
homeassistant/components/matter/__init__.py
|
||||||
homeassistant/components/meater/__init__.py
|
homeassistant/components/meater/__init__.py
|
||||||
homeassistant/components/meater/sensor.py
|
homeassistant/components/meater/sensor.py
|
||||||
|
homeassistant/components/medcom_ble/__init__.py
|
||||||
|
homeassistant/components/medcom_ble/sensor.py
|
||||||
homeassistant/components/mediaroom/media_player.py
|
homeassistant/components/mediaroom/media_player.py
|
||||||
homeassistant/components/melcloud/__init__.py
|
homeassistant/components/melcloud/__init__.py
|
||||||
homeassistant/components/melcloud/climate.py
|
homeassistant/components/melcloud/climate.py
|
||||||
|
@ -742,6 +742,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/mazda/ @bdr99
|
/tests/components/mazda/ @bdr99
|
||||||
/homeassistant/components/meater/ @Sotolotl @emontnemery
|
/homeassistant/components/meater/ @Sotolotl @emontnemery
|
||||||
/tests/components/meater/ @Sotolotl @emontnemery
|
/tests/components/meater/ @Sotolotl @emontnemery
|
||||||
|
/homeassistant/components/medcom_ble/ @elafargue
|
||||||
|
/tests/components/medcom_ble/ @elafargue
|
||||||
/homeassistant/components/media_extractor/ @joostlek
|
/homeassistant/components/media_extractor/ @joostlek
|
||||||
/tests/components/media_extractor/ @joostlek
|
/tests/components/media_extractor/ @joostlek
|
||||||
/homeassistant/components/media_player/ @home-assistant/core
|
/homeassistant/components/media_player/ @home-assistant/core
|
||||||
|
74
homeassistant/components/medcom_ble/__init__.py
Normal file
74
homeassistant/components/medcom_ble/__init__.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""The Medcom BLE integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from bleak import BleakError
|
||||||
|
from medcom_ble import MedcomBleDeviceData
|
||||||
|
|
||||||
|
from homeassistant.components import bluetooth
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||||
|
|
||||||
|
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||||
|
|
||||||
|
# Supported platforms
|
||||||
|
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Medcom BLE radiation monitor from a config entry."""
|
||||||
|
|
||||||
|
address = entry.unique_id
|
||||||
|
elevation = hass.config.elevation
|
||||||
|
is_metric = hass.config.units is METRIC_SYSTEM
|
||||||
|
assert address is not None
|
||||||
|
|
||||||
|
ble_device = bluetooth.async_ble_device_from_address(hass, address)
|
||||||
|
if not ble_device:
|
||||||
|
raise ConfigEntryNotReady(
|
||||||
|
f"Could not find Medcom BLE device with address {address}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_method():
|
||||||
|
"""Get data from Medcom BLE radiation monitor."""
|
||||||
|
ble_device = bluetooth.async_ble_device_from_address(hass, address)
|
||||||
|
inspector = MedcomBleDeviceData(_LOGGER, elevation, is_metric)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await inspector.update_device(ble_device)
|
||||||
|
except BleakError as err:
|
||||||
|
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
coordinator = DataUpdateCoordinator(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_method=_async_update_method,
|
||||||
|
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||||
|
|
||||||
|
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
|
147
homeassistant/components/medcom_ble/config_flow.py
Normal file
147
homeassistant/components/medcom_ble/config_flow.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
"""Config flow for Medcom BlE integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bleak import BleakError
|
||||||
|
from bluetooth_data_tools import human_readable_name
|
||||||
|
from medcom_ble import MedcomBleDevice, MedcomBleDeviceData
|
||||||
|
from medcom_ble.const import INSPECTOR_SERVICE_UUID
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import bluetooth
|
||||||
|
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 AbortFlow, FlowResult
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InspectorBLEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Medcom BLE radiation monitors."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the config flow."""
|
||||||
|
self._discovery_info: BluetoothServiceInfo | None = None
|
||||||
|
self._discovered_devices: dict[str, BluetoothServiceInfo] = {}
|
||||||
|
|
||||||
|
async def _get_device_data(
|
||||||
|
self, service_info: BluetoothServiceInfo
|
||||||
|
) -> MedcomBleDevice:
|
||||||
|
ble_device = bluetooth.async_ble_device_from_address(
|
||||||
|
self.hass, service_info.address
|
||||||
|
)
|
||||||
|
if ble_device is None:
|
||||||
|
_LOGGER.debug("no ble_device in _get_device_data")
|
||||||
|
raise AbortFlow("cannot_connect")
|
||||||
|
|
||||||
|
inspector = MedcomBleDeviceData(_LOGGER)
|
||||||
|
|
||||||
|
return await inspector.update_device(ble_device)
|
||||||
|
|
||||||
|
async def async_step_bluetooth(
|
||||||
|
self, discovery_info: BluetoothServiceInfo
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the bluetooth discovery step."""
|
||||||
|
_LOGGER.debug("Discovered BLE device: %s", discovery_info.name)
|
||||||
|
await self.async_set_unique_id(discovery_info.address)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
self._discovery_info = discovery_info
|
||||||
|
self.context["title_placeholders"] = {
|
||||||
|
"name": human_readable_name(
|
||||||
|
None, discovery_info.name, discovery_info.address
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.async_step_bluetooth_confirm()
|
||||||
|
|
||||||
|
async def async_step_bluetooth_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Confirm discovery."""
|
||||||
|
# We always will have self._discovery_info be a BluetoothServiceInfo at this point
|
||||||
|
# and this helps mypy not complain
|
||||||
|
assert self._discovery_info is not None
|
||||||
|
|
||||||
|
if user_input is None:
|
||||||
|
name = self._discovery_info.name or self._discovery_info.address
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="bluetooth_confirm",
|
||||||
|
description_placeholders={"name": name},
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.async_step_check_connection()
|
||||||
|
|
||||||
|
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()
|
||||||
|
self._discovery_info = self._discovered_devices[address]
|
||||||
|
return await self.async_step_check_connection()
|
||||||
|
|
||||||
|
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:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Detected a device that's already configured: %s", address
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if INSPECTOR_SERVICE_UUID not in discovery_info.service_uuids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._discovered_devices[discovery_info.address] = discovery_info
|
||||||
|
|
||||||
|
if not self._discovered_devices:
|
||||||
|
return self.async_abort(reason="no_devices_found")
|
||||||
|
|
||||||
|
titles = {
|
||||||
|
address: discovery.name
|
||||||
|
for address, discovery in self._discovered_devices.items()
|
||||||
|
}
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_ADDRESS): vol.In(titles),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_check_connection(self) -> FlowResult:
|
||||||
|
"""Check we can connect to the device before considering the configuration is successful."""
|
||||||
|
# We always will have self._discovery_info be a BluetoothServiceInfo at this point
|
||||||
|
# and this helps mypy not complain
|
||||||
|
assert self._discovery_info is not None
|
||||||
|
|
||||||
|
_LOGGER.debug("Checking device connection: %s", self._discovery_info.name)
|
||||||
|
try:
|
||||||
|
await self._get_device_data(self._discovery_info)
|
||||||
|
except BleakError:
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
except AbortFlow:
|
||||||
|
raise
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception(
|
||||||
|
"Error occurred reading information from %s: %s",
|
||||||
|
self._discovery_info.address,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return self.async_abort(reason="unknown")
|
||||||
|
_LOGGER.debug("Device connection successful, proceeding")
|
||||||
|
return self.async_create_entry(title=self._discovery_info.name, data={})
|
10
homeassistant/components/medcom_ble/const.py
Normal file
10
homeassistant/components/medcom_ble/const.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""Constants for the Medcom BLE integration."""
|
||||||
|
|
||||||
|
DOMAIN = "medcom_ble"
|
||||||
|
|
||||||
|
# 5 minutes scan interval, which is perfectly
|
||||||
|
# adequate for background monitoring
|
||||||
|
DEFAULT_SCAN_INTERVAL = 300
|
||||||
|
|
||||||
|
# Units for the radiation monitors
|
||||||
|
UNIT_CPM = "CPM"
|
15
homeassistant/components/medcom_ble/manifest.json
Normal file
15
homeassistant/components/medcom_ble/manifest.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"domain": "medcom_ble",
|
||||||
|
"name": "Medcom Bluetooth",
|
||||||
|
"bluetooth": [
|
||||||
|
{
|
||||||
|
"service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"codeowners": ["@elafargue"],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["bluetooth_adapters"],
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/medcom_ble",
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"requirements": ["medcom-ble==0.1.1"]
|
||||||
|
}
|
104
homeassistant/components/medcom_ble/sensor.py
Normal file
104
homeassistant/components/medcom_ble/sensor.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"""Support for Medcom BLE radiation monitor sensors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from medcom_ble import MedcomBleDevice
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
CoordinatorEntity,
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import DOMAIN, UNIT_CPM
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||||
|
"cpm": SensorEntityDescription(
|
||||||
|
key="cpm",
|
||||||
|
translation_key="cpm",
|
||||||
|
native_unit_of_measurement=UNIT_CPM,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
icon="mdi:radioactive",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: config_entries.ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Medcom BLE radiation monitor sensors."""
|
||||||
|
|
||||||
|
coordinator: DataUpdateCoordinator[MedcomBleDevice] = hass.data[DOMAIN][
|
||||||
|
entry.entry_id
|
||||||
|
]
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
_LOGGER.debug("got sensors: %s", coordinator.data.sensors)
|
||||||
|
for sensor_type, sensor_value in coordinator.data.sensors.items():
|
||||||
|
if sensor_type not in SENSORS_MAPPING_TEMPLATE:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Unknown sensor type detected: %s, %s",
|
||||||
|
sensor_type,
|
||||||
|
sensor_value,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
entities.append(
|
||||||
|
MedcomSensor(coordinator, SENSORS_MAPPING_TEMPLATE[sensor_type])
|
||||||
|
)
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
class MedcomSensor(
|
||||||
|
CoordinatorEntity[DataUpdateCoordinator[MedcomBleDevice]], SensorEntity
|
||||||
|
):
|
||||||
|
"""Medcom BLE radiation monitor sensors for the device."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: DataUpdateCoordinator[MedcomBleDevice],
|
||||||
|
entity_description: SensorEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Populate the medcom entity with relevant data."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description = entity_description
|
||||||
|
medcom_device = coordinator.data
|
||||||
|
|
||||||
|
name = medcom_device.name
|
||||||
|
if identifier := medcom_device.identifier:
|
||||||
|
name += f" ({identifier})"
|
||||||
|
|
||||||
|
self._attr_unique_id = f"{medcom_device.address}_{entity_description.key}"
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
connections={
|
||||||
|
(
|
||||||
|
CONNECTION_BLUETOOTH,
|
||||||
|
medcom_device.address,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
name=name,
|
||||||
|
manufacturer=medcom_device.manufacturer,
|
||||||
|
hw_version=medcom_device.hw_version,
|
||||||
|
sw_version=medcom_device.sw_version,
|
||||||
|
model=medcom_device.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> float:
|
||||||
|
"""Return the value reported by the sensor."""
|
||||||
|
return self.coordinator.data.sensors[self.entity_description.key]
|
30
homeassistant/components/medcom_ble/strings.json
Normal file
30
homeassistant/components/medcom_ble/strings.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"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%]",
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"cpm": {
|
||||||
|
"name": "Counts per minute"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -304,6 +304,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
|
|||||||
"domain": "led_ble",
|
"domain": "led_ble",
|
||||||
"local_name": "AP-*",
|
"local_name": "AP-*",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"domain": "medcom_ble",
|
||||||
|
"service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "melnor",
|
"domain": "melnor",
|
||||||
"manufacturer_data_start": [
|
"manufacturer_data_start": [
|
||||||
|
@ -271,6 +271,7 @@ FLOWS = {
|
|||||||
"matter",
|
"matter",
|
||||||
"mazda",
|
"mazda",
|
||||||
"meater",
|
"meater",
|
||||||
|
"medcom_ble",
|
||||||
"melcloud",
|
"melcloud",
|
||||||
"melnor",
|
"melnor",
|
||||||
"met",
|
"met",
|
||||||
|
@ -3270,6 +3270,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "cloud_polling"
|
||||||
},
|
},
|
||||||
|
"medcom_ble": {
|
||||||
|
"name": "Medcom Bluetooth",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling"
|
||||||
|
},
|
||||||
"media_extractor": {
|
"media_extractor": {
|
||||||
"name": "Media Extractor",
|
"name": "Media Extractor",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
@ -1197,6 +1197,9 @@ mcstatus==11.0.0
|
|||||||
# homeassistant.components.meater
|
# homeassistant.components.meater
|
||||||
meater-python==0.0.8
|
meater-python==0.0.8
|
||||||
|
|
||||||
|
# homeassistant.components.medcom_ble
|
||||||
|
medcom-ble==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.melnor
|
# homeassistant.components.melnor
|
||||||
melnor-bluetooth==0.0.25
|
melnor-bluetooth==0.0.25
|
||||||
|
|
||||||
|
@ -929,6 +929,9 @@ mcstatus==11.0.0
|
|||||||
# homeassistant.components.meater
|
# homeassistant.components.meater
|
||||||
meater-python==0.0.8
|
meater-python==0.0.8
|
||||||
|
|
||||||
|
# homeassistant.components.medcom_ble
|
||||||
|
medcom-ble==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.melnor
|
# homeassistant.components.melnor
|
||||||
melnor-bluetooth==0.0.25
|
melnor-bluetooth==0.0.25
|
||||||
|
|
||||||
|
111
tests/components/medcom_ble/__init__.py
Normal file
111
tests/components/medcom_ble/__init__.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Tests for the Medcom Inspector BLE integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from medcom_ble import MedcomBleDevice, MedcomBleDeviceData
|
||||||
|
|
||||||
|
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
|
||||||
|
|
||||||
|
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
|
||||||
|
|
||||||
|
|
||||||
|
def patch_async_setup_entry(return_value=True):
|
||||||
|
"""Patch async setup entry to return True."""
|
||||||
|
return patch(
|
||||||
|
"homeassistant.components.medcom_ble.async_setup_entry",
|
||||||
|
return_value=return_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None):
|
||||||
|
"""Patch async ble device from address to return a given value."""
|
||||||
|
return patch(
|
||||||
|
"homeassistant.components.bluetooth.async_ble_device_from_address",
|
||||||
|
return_value=return_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_medcom_ble(return_value=MedcomBleDevice, side_effect=None):
|
||||||
|
"""Patch medcom-ble device fetcher with given values and effects."""
|
||||||
|
return patch.object(
|
||||||
|
MedcomBleDeviceData,
|
||||||
|
"update_device",
|
||||||
|
return_value=return_value,
|
||||||
|
side_effect=side_effect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MEDCOM_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||||
|
name="InspectorBLE-D9A0",
|
||||||
|
address="a0:d9:5a:57:0b:00",
|
||||||
|
device=generate_ble_device(
|
||||||
|
address="a0:d9:5a:57:0b:00",
|
||||||
|
name="InspectorBLE-D9A0",
|
||||||
|
),
|
||||||
|
rssi=-54,
|
||||||
|
manufacturer_data={},
|
||||||
|
service_data={
|
||||||
|
# Sensor data
|
||||||
|
"d68236af-266f-4486-b42d-80356ed5afb7": bytearray(b" 45,"),
|
||||||
|
# Manufacturer
|
||||||
|
"00002a29-0000-1000-8000-00805f9b34fb": bytearray(b"International Medcom"),
|
||||||
|
# Model
|
||||||
|
"00002a24-0000-1000-8000-00805f9b34fb": bytearray(b"Inspector-BLE"),
|
||||||
|
# Identifier
|
||||||
|
"00002a25-0000-1000-8000-00805f9b34fb": bytearray(b"\xa0\xd9\x5a\x57\x0b\x00"),
|
||||||
|
# SW Version
|
||||||
|
"00002a26-0000-1000-8000-00805f9b34fb": bytearray(b"170602"),
|
||||||
|
# HW Version
|
||||||
|
"00002a27-0000-1000-8000-00805f9b34fb": bytearray(b"2.0"),
|
||||||
|
},
|
||||||
|
service_uuids=[
|
||||||
|
"39b31fec-b63a-4ef7-b163-a7317872007f",
|
||||||
|
"00002a29-0000-1000-8000-00805f9b34fb",
|
||||||
|
"00002a24-0000-1000-8000-00805f9b34fb",
|
||||||
|
"00002a25-0000-1000-8000-00805f9b34fb",
|
||||||
|
"00002a26-0000-1000-8000-00805f9b34fb",
|
||||||
|
"00002a27-0000-1000-8000-00805f9b34fb",
|
||||||
|
],
|
||||||
|
source="local",
|
||||||
|
advertisement=generate_advertisement_data(
|
||||||
|
tx_power=8,
|
||||||
|
service_uuids=["39b31fec-b63a-4ef7-b163-a7317872007f"],
|
||||||
|
),
|
||||||
|
connectable=True,
|
||||||
|
time=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||||
|
name="unknown",
|
||||||
|
address="00:cc:cc:cc:cc:cc",
|
||||||
|
rssi=-61,
|
||||||
|
manufacturer_data={},
|
||||||
|
service_data={},
|
||||||
|
service_uuids=[],
|
||||||
|
source="local",
|
||||||
|
device=generate_ble_device(
|
||||||
|
"00:cc:cc:cc:cc:cc",
|
||||||
|
"unknown",
|
||||||
|
),
|
||||||
|
advertisement=generate_advertisement_data(
|
||||||
|
manufacturer_data={},
|
||||||
|
service_uuids=[],
|
||||||
|
),
|
||||||
|
connectable=True,
|
||||||
|
time=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
MEDCOM_DEVICE_INFO = MedcomBleDevice(
|
||||||
|
manufacturer="International Medcom",
|
||||||
|
hw_version="2.0",
|
||||||
|
sw_version="170602",
|
||||||
|
model="Inspector BLE",
|
||||||
|
model_raw="InspectorBLE-D9A0",
|
||||||
|
name="Inspector BLE",
|
||||||
|
identifier="a0d95a570b00",
|
||||||
|
sensors={
|
||||||
|
"cpm": 45,
|
||||||
|
},
|
||||||
|
address="a0:d9:5a:57:0b:00",
|
||||||
|
)
|
8
tests/components/medcom_ble/conftest.py
Normal file
8
tests/components/medcom_ble/conftest.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""Common fixtures for the Medcom Inspector BLE tests."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_bluetooth(enable_bluetooth):
|
||||||
|
"""Auto mock bluetooth."""
|
218
tests/components/medcom_ble/test_config_flow.py
Normal file
218
tests/components/medcom_ble/test_config_flow.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
"""Test the Medcom Inspector BLE config flow."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bleak import BleakError
|
||||||
|
from medcom_ble import MedcomBleDevice
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.medcom_ble.const import DOMAIN
|
||||||
|
from homeassistant.const import CONF_ADDRESS
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
MEDCOM_DEVICE_INFO,
|
||||||
|
MEDCOM_SERVICE_INFO,
|
||||||
|
UNKNOWN_SERVICE_INFO,
|
||||||
|
patch_async_ble_device_from_address,
|
||||||
|
patch_async_setup_entry,
|
||||||
|
patch_medcom_ble,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
|
||||||
|
"""Test discovery via bluetooth with a valid device."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||||
|
data=MEDCOM_SERVICE_INFO,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "bluetooth_confirm"
|
||||||
|
assert result["description_placeholders"] == {"name": "InspectorBLE-D9A0"}
|
||||||
|
|
||||||
|
with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble(
|
||||||
|
MedcomBleDevice(
|
||||||
|
manufacturer="International Medcom",
|
||||||
|
model="Inspector BLE",
|
||||||
|
model_raw="Inspector-BLE",
|
||||||
|
name="Inspector BLE",
|
||||||
|
identifier="a0d95a570b00",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
with patch_async_setup_entry():
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={"not": "empty"}
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "InspectorBLE-D9A0"
|
||||||
|
assert result["result"].unique_id == "a0:d9:5a:57:0b:00"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None:
|
||||||
|
"""Test discovery via bluetooth with a valid device when already setup."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id="a0:d9:5a:57:0b:00",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_BLUETOOTH},
|
||||||
|
data=MEDCOM_DEVICE_INFO,
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_setup(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the user initiated form."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.medcom_ble.config_flow.async_discovered_service_info",
|
||||||
|
return_value=[MEDCOM_SERVICE_INFO],
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"] is None
|
||||||
|
assert result["data_schema"] is not None
|
||||||
|
schema = result["data_schema"].schema
|
||||||
|
|
||||||
|
assert schema.get(CONF_ADDRESS).container == {
|
||||||
|
"a0:d9:5a:57:0b:00": "InspectorBLE-D9A0"
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble(
|
||||||
|
MedcomBleDevice(
|
||||||
|
manufacturer="International Medcom",
|
||||||
|
model="Inspector BLE",
|
||||||
|
model_raw="Inspector-BLE",
|
||||||
|
name="Inspector BLE",
|
||||||
|
identifier="a0d95a570b00",
|
||||||
|
)
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.medcom_ble.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "InspectorBLE-D9A0"
|
||||||
|
assert result["result"].unique_id == "a0:d9:5a:57:0b:00"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_setup_no_device(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the user initiated form without any device detected."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.medcom_ble.config_flow.async_discovered_service_info",
|
||||||
|
return_value=[],
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "no_devices_found"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_setup_existing_and_unknown_device(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the user initiated form with existing devices and unknown ones."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id="00:cc:cc:cc:cc:cc",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.medcom_ble.config_flow.async_discovered_service_info",
|
||||||
|
return_value=[UNKNOWN_SERVICE_INFO, MEDCOM_SERVICE_INFO],
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"] is None
|
||||||
|
assert result["data_schema"] is not None
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "cannot_connect"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_setup_unknown_device(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the user initiated form with only unknown devices."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.medcom_ble.config_flow.async_discovered_service_info",
|
||||||
|
return_value=[UNKNOWN_SERVICE_INFO],
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "no_devices_found"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_setup_unknown_error(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the user initiated form with an unknown error."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.medcom_ble.config_flow.async_discovered_service_info",
|
||||||
|
return_value=[MEDCOM_SERVICE_INFO],
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"] is None
|
||||||
|
assert result["data_schema"] is not None
|
||||||
|
|
||||||
|
with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble(
|
||||||
|
None, Exception()
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_setup_unable_to_connect(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the user initiated form with a device that's failing connection."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.medcom_ble.config_flow.async_discovered_service_info",
|
||||||
|
return_value=[MEDCOM_SERVICE_INFO],
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"] is None
|
||||||
|
assert result["data_schema"] is not None
|
||||||
|
schema = result["data_schema"].schema
|
||||||
|
|
||||||
|
assert schema.get(CONF_ADDRESS).container == {
|
||||||
|
"a0:d9:5a:57:0b:00": "InspectorBLE-D9A0"
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble(
|
||||||
|
side_effect=BleakError("An error")
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "cannot_connect"
|
Loading…
x
Reference in New Issue
Block a user