From 402859697708b12feba1a7722e8721b07fd50b54 Mon Sep 17 00:00:00 2001 From: Edouard Lafargue Date: Tue, 26 Sep 2023 11:41:34 -0700 Subject: [PATCH] Add Medcom Bluetooth integration (#100289) Co-authored-by: J. Nick Koston --- .coveragerc | 2 + CODEOWNERS | 2 + .../components/medcom_ble/__init__.py | 74 ++++++ .../components/medcom_ble/config_flow.py | 147 ++++++++++++ homeassistant/components/medcom_ble/const.py | 10 + .../components/medcom_ble/manifest.json | 15 ++ homeassistant/components/medcom_ble/sensor.py | 104 +++++++++ .../components/medcom_ble/strings.json | 30 +++ homeassistant/generated/bluetooth.py | 4 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/medcom_ble/__init__.py | 111 +++++++++ tests/components/medcom_ble/conftest.py | 8 + .../components/medcom_ble/test_config_flow.py | 218 ++++++++++++++++++ 16 files changed, 738 insertions(+) create mode 100644 homeassistant/components/medcom_ble/__init__.py create mode 100644 homeassistant/components/medcom_ble/config_flow.py create mode 100644 homeassistant/components/medcom_ble/const.py create mode 100644 homeassistant/components/medcom_ble/manifest.json create mode 100644 homeassistant/components/medcom_ble/sensor.py create mode 100644 homeassistant/components/medcom_ble/strings.json create mode 100644 tests/components/medcom_ble/__init__.py create mode 100644 tests/components/medcom_ble/conftest.py create mode 100644 tests/components/medcom_ble/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 21932b67437..b2beacfe5a5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -726,6 +726,8 @@ omit = homeassistant/components/matter/__init__.py homeassistant/components/meater/__init__.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/melcloud/__init__.py homeassistant/components/melcloud/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index e728d70c1bc..1005fb80094 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -742,6 +742,8 @@ build.json @home-assistant/supervisor /tests/components/mazda/ @bdr99 /homeassistant/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery +/homeassistant/components/medcom_ble/ @elafargue +/tests/components/medcom_ble/ @elafargue /homeassistant/components/media_extractor/ @joostlek /tests/components/media_extractor/ @joostlek /homeassistant/components/media_player/ @home-assistant/core diff --git a/homeassistant/components/medcom_ble/__init__.py b/homeassistant/components/medcom_ble/__init__.py new file mode 100644 index 00000000000..a129c4fc7f9 --- /dev/null +++ b/homeassistant/components/medcom_ble/__init__.py @@ -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 diff --git a/homeassistant/components/medcom_ble/config_flow.py b/homeassistant/components/medcom_ble/config_flow.py new file mode 100644 index 00000000000..30a87afbb72 --- /dev/null +++ b/homeassistant/components/medcom_ble/config_flow.py @@ -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={}) diff --git a/homeassistant/components/medcom_ble/const.py b/homeassistant/components/medcom_ble/const.py new file mode 100644 index 00000000000..3929b5d302b --- /dev/null +++ b/homeassistant/components/medcom_ble/const.py @@ -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" diff --git a/homeassistant/components/medcom_ble/manifest.json b/homeassistant/components/medcom_ble/manifest.json new file mode 100644 index 00000000000..4aacae4647d --- /dev/null +++ b/homeassistant/components/medcom_ble/manifest.json @@ -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"] +} diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py new file mode 100644 index 00000000000..4c7488ddc12 --- /dev/null +++ b/homeassistant/components/medcom_ble/sensor.py @@ -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] diff --git a/homeassistant/components/medcom_ble/strings.json b/homeassistant/components/medcom_ble/strings.json new file mode 100644 index 00000000000..6ea6c0566ed --- /dev/null +++ b/homeassistant/components/medcom_ble/strings.json @@ -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" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 5784667bc67..c2b24b68d29 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -304,6 +304,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "led_ble", "local_name": "AP-*", }, + { + "domain": "medcom_ble", + "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f", + }, { "domain": "melnor", "manufacturer_data_start": [ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d240f868b3e..acf324c107f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -271,6 +271,7 @@ FLOWS = { "matter", "mazda", "meater", + "medcom_ble", "melcloud", "melnor", "met", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9ebe29c8a48..394bfa4f391 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3270,6 +3270,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "medcom_ble": { + "name": "Medcom Bluetooth", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "media_extractor": { "name": "Media Extractor", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index a714a4e64b4..8c2ef0e4b71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1197,6 +1197,9 @@ mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.medcom_ble +medcom-ble==0.1.1 + # homeassistant.components.melnor melnor-bluetooth==0.0.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26dc3625f09..3da744aed73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -929,6 +929,9 @@ mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.medcom_ble +medcom-ble==0.1.1 + # homeassistant.components.melnor melnor-bluetooth==0.0.25 diff --git a/tests/components/medcom_ble/__init__.py b/tests/components/medcom_ble/__init__.py new file mode 100644 index 00000000000..e38b8ce8f01 --- /dev/null +++ b/tests/components/medcom_ble/__init__.py @@ -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", +) diff --git a/tests/components/medcom_ble/conftest.py b/tests/components/medcom_ble/conftest.py new file mode 100644 index 00000000000..7c5b0dad22e --- /dev/null +++ b/tests/components/medcom_ble/conftest.py @@ -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.""" diff --git a/tests/components/medcom_ble/test_config_flow.py b/tests/components/medcom_ble/test_config_flow.py new file mode 100644 index 00000000000..620b6811757 --- /dev/null +++ b/tests/components/medcom_ble/test_config_flow.py @@ -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"