diff --git a/CODEOWNERS b/CODEOWNERS index 72107041575..be7c1e5ee84 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1178,6 +1178,8 @@ build.json @home-assistant/supervisor /tests/components/powerwall/ @bdraco @jrester @daniel-simpson /homeassistant/components/private_ble_device/ @Jc2k /tests/components/private_ble_device/ @Jc2k +/homeassistant/components/probe_plus/ @pantherale0 +/tests/components/probe_plus/ @pantherale0 /homeassistant/components/profiler/ @bdraco /tests/components/profiler/ @bdraco /homeassistant/components/progettihwsw/ @ardaseremet diff --git a/homeassistant/components/probe_plus/__init__.py b/homeassistant/components/probe_plus/__init__.py new file mode 100644 index 00000000000..be1faf4a297 --- /dev/null +++ b/homeassistant/components/probe_plus/__init__.py @@ -0,0 +1,24 @@ +"""The Probe Plus integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import ProbePlusConfigEntry, ProbePlusDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ProbePlusConfigEntry) -> bool: + """Set up Probe Plus from a config entry.""" + coordinator = ProbePlusDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ProbePlusConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/probe_plus/config_flow.py b/homeassistant/components/probe_plus/config_flow.py new file mode 100644 index 00000000000..1e9a858e9fc --- /dev/null +++ b/homeassistant/components/probe_plus/config_flow.py @@ -0,0 +1,125 @@ +"""Config flow for probe_plus integration.""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class Discovery: + """Represents a discovered Bluetooth device. + + Attributes: + title: The name or title of the discovered device. + discovery_info: Information about the discovered device. + + """ + + title: str + discovery_info: BluetoothServiceInfo + + +def title(discovery_info: BluetoothServiceInfo) -> str: + """Return a title for the discovered device.""" + return f"{discovery_info.name} {discovery_info.address}" + + +class ProbeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BT Probe.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_devices: dict[str, Discovery] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered BT device: %s", discovery_info) + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = {"name": title(discovery_info)} + self._discovered_devices[discovery_info.address] = Discovery( + title(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 + ) -> ConfigFlowResult: + """Handle the bluetooth confirmation step.""" + if user_input is not None: + assert self.unique_id + self._abort_if_unique_id_configured() + discovery = self._discovered_devices[self.unique_id] + return self.async_create_entry( + title=discovery.title, + data={ + CONF_ADDRESS: discovery.discovery_info.address, + }, + ) + self._set_confirm_only() + assert self.unique_id + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={ + "name": title(self._discovered_devices[self.unique_id].discovery_info) + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """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() + discovery = self._discovered_devices[address] + return self.async_create_entry( + title=discovery.title, + data=user_input, + ) + + 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 + + self._discovered_devices[address] = Discovery( + title(discovery_info), discovery_info + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + titles = { + address: discovery.title + 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), + } + ), + ) diff --git a/homeassistant/components/probe_plus/const.py b/homeassistant/components/probe_plus/const.py new file mode 100644 index 00000000000..d0e2a7d6992 --- /dev/null +++ b/homeassistant/components/probe_plus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Probe Plus integration.""" + +DOMAIN = "probe_plus" diff --git a/homeassistant/components/probe_plus/coordinator.py b/homeassistant/components/probe_plus/coordinator.py new file mode 100644 index 00000000000..b712e3fc84b --- /dev/null +++ b/homeassistant/components/probe_plus/coordinator.py @@ -0,0 +1,68 @@ +"""Coordinator for the probe_plus integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyprobeplus import ProbePlusDevice +from pyprobeplus.exceptions import ProbePlusDeviceNotFound, ProbePlusError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +type ProbePlusConfigEntry = ConfigEntry[ProbePlusDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=15) + + +class ProbePlusDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Coordinator to manage data updates for a probe device. + + This class handles the communication with Probe Plus devices. + + Data is updated by the device itself. + """ + + config_entry: ProbePlusConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ProbePlusConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ProbePlusDataUpdateCoordinator", + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + + self.device: ProbePlusDevice = ProbePlusDevice( + address_or_ble_device=entry.data[CONF_ADDRESS], + name=entry.title, + notify_callback=self.async_update_listeners, + ) + + async def _async_update_data(self) -> None: + """Connect to the Probe Plus device on a set interval. + + This method is called periodically to reconnect to the device + Data updates are handled by the device itself. + """ + # Already connected, no need to update any data as the device streams this. + if self.device.connected: + return + + # Probe is not connected, try to connect + try: + await self.device.connect() + except (ProbePlusError, ProbePlusDeviceNotFound, TimeoutError) as e: + _LOGGER.debug( + "Could not connect to scale: %s, Error: %s", + self.config_entry.data[CONF_ADDRESS], + e, + ) + self.device.device_disconnected_handler(notify=False) + return diff --git a/homeassistant/components/probe_plus/entity.py b/homeassistant/components/probe_plus/entity.py new file mode 100644 index 00000000000..c2c53f5bca4 --- /dev/null +++ b/homeassistant/components/probe_plus/entity.py @@ -0,0 +1,54 @@ +"""Probe Plus base entity type.""" + +from dataclasses import dataclass + +from pyprobeplus import ProbePlusDevice + +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ProbePlusDataUpdateCoordinator + + +@dataclass +class ProbePlusEntity(CoordinatorEntity[ProbePlusDataUpdateCoordinator]): + """Base class for Probe Plus entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ProbePlusDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + + # Set the unique ID for the entity + self._attr_unique_id = ( + f"{format_mac(coordinator.device.mac)}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(coordinator.device.mac))}, + name=coordinator.device.name, + manufacturer="Probe Plus", + suggested_area="Kitchen", + connections={(CONNECTION_BLUETOOTH, coordinator.device.mac)}, + ) + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return super().available and self.coordinator.device.connected + + @property + def device(self) -> ProbePlusDevice: + """Return the device associated with this entity.""" + return self.coordinator.device diff --git a/homeassistant/components/probe_plus/icons.json b/homeassistant/components/probe_plus/icons.json new file mode 100644 index 00000000000..d76bbd39873 --- /dev/null +++ b/homeassistant/components/probe_plus/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "probe_temperature": { + "default": "mdi:thermometer-bluetooth" + } + } + } +} diff --git a/homeassistant/components/probe_plus/manifest.json b/homeassistant/components/probe_plus/manifest.json new file mode 100644 index 00000000000..cf61e394a83 --- /dev/null +++ b/homeassistant/components/probe_plus/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "probe_plus", + "name": "Probe Plus", + "bluetooth": [ + { + "connectable": true, + "manufacturer_id": 36606, + "local_name": "FM2*" + } + ], + "codeowners": ["@pantherale0"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/probe_plus", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["pyprobeplus==1.0.0"] +} diff --git a/homeassistant/components/probe_plus/quality_scale.yaml b/homeassistant/components/probe_plus/quality_scale.yaml new file mode 100644 index 00000000000..d06d36d41de --- /dev/null +++ b/homeassistant/components/probe_plus/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: exempt + comment: | + Device is expected to be offline most of the time, but needs to connect quickly once available. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: | + Handled by coordinator. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + No authentication required. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No IP discovery. + discovery: + status: done + comment: | + The integration uses Bluetooth discovery to find devices. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: | + No custom exceptions are defined. + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + No reconfiguration flow is needed as the only thing that could be changed is the MAC, which is already hardcoded on the device itself. + repair-issues: + status: exempt + comment: | + No repair issues. + stale-devices: + status: exempt + comment: | + The device itself is the integration. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + No web session is used. + strict-typing: todo diff --git a/homeassistant/components/probe_plus/sensor.py b/homeassistant/components/probe_plus/sensor.py new file mode 100644 index 00000000000..9834a1433a4 --- /dev/null +++ b/homeassistant/components/probe_plus/sensor.py @@ -0,0 +1,106 @@ +"""Support for Probe Plus BLE sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricPotential, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ProbePlusConfigEntry, ProbePlusDevice +from .entity import ProbePlusEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class ProbePlusSensorEntityDescription(SensorEntityDescription): + """Description for Probe Plus sensor entities.""" + + value_fn: Callable[[ProbePlusDevice], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[ProbePlusSensorEntityDescription, ...] = ( + ProbePlusSensorEntityDescription( + key="probe_temperature", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda device: device.device_state.probe_temperature, + device_class=SensorDeviceClass.TEMPERATURE, + ), + ProbePlusSensorEntityDescription( + key="probe_battery", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.device_state.probe_battery, + device_class=SensorDeviceClass.BATTERY, + ), + ProbePlusSensorEntityDescription( + key="relay_battery", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.device_state.relay_battery, + device_class=SensorDeviceClass.BATTERY, + ), + ProbePlusSensorEntityDescription( + key="probe_rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.device_state.probe_rssi, + entity_registry_enabled_default=False, + ), + ProbePlusSensorEntityDescription( + key="relay_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLTAGE, + value_fn=lambda device: device.device_state.relay_voltage, + entity_registry_enabled_default=False, + ), + ProbePlusSensorEntityDescription( + key="probe_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLTAGE, + value_fn=lambda device: device.device_state.probe_voltage, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ProbePlusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Probe Plus sensors.""" + coordinator = entry.runtime_data + async_add_entities(ProbeSensor(coordinator, desc) for desc in SENSOR_DESCRIPTIONS) + + +class ProbeSensor(ProbePlusEntity, RestoreSensor): + """Representation of a Probe Plus sensor.""" + + entity_description: ProbePlusSensorEntityDescription + + @property + def native_value(self) -> int | float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/probe_plus/strings.json b/homeassistant/components/probe_plus/strings.json new file mode 100644 index 00000000000..45fd4be39ce --- /dev/null +++ b/homeassistant/components/probe_plus/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "flow_title": "{name}", + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + }, + "error": { + "device_not_found": "Device could not be found.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Select BLE probe you want to set up" + } + } + } + }, + "entity": { + "sensor": { + "probe_battery": { + "name": "Probe battery" + }, + "probe_temperature": { + "name": "Probe temperature" + }, + "probe_rssi": { + "name": "Probe RSSI" + }, + "probe_voltage": { + "name": "Probe voltage" + }, + "relay_battery": { + "name": "Relay battery" + }, + "relay_voltage": { + "name": "Relay voltage" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index e796625f81c..f5303f09302 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -593,6 +593,12 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "oralb", "manufacturer_id": 220, }, + { + "connectable": True, + "domain": "probe_plus", + "local_name": "FM2*", + "manufacturer_id": 36606, + }, { "connectable": False, "domain": "qingping", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1b7536ed4b9..e1211ac20d0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -487,6 +487,7 @@ FLOWS = { "powerfox", "powerwall", "private_ble_device", + "probe_plus", "profiler", "progettihwsw", "prosegur", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 66addc2f5b5..7f335f4091d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5048,6 +5048,12 @@ "config_flow": true, "iot_class": "local_push" }, + "probe_plus": { + "name": "Probe Plus", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "profiler": { "name": "Profiler", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index edb6716b10b..7b8ee92a996 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2244,6 +2244,9 @@ pyplaato==0.0.19 # homeassistant.components.point pypoint==3.0.0 +# homeassistant.components.probe_plus +pyprobeplus==1.0.0 + # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7990cfd6e25..f298814e015 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1838,6 +1838,9 @@ pyplaato==0.0.19 # homeassistant.components.point pypoint==3.0.0 +# homeassistant.components.probe_plus +pyprobeplus==1.0.0 + # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/tests/components/probe_plus/__init__.py b/tests/components/probe_plus/__init__.py new file mode 100644 index 00000000000..22f0d7dd1c3 --- /dev/null +++ b/tests/components/probe_plus/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Probe Plus integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Probe Plus integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/probe_plus/conftest.py b/tests/components/probe_plus/conftest.py new file mode 100644 index 00000000000..ddbad5c46b1 --- /dev/null +++ b/tests/components/probe_plus/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the Probe Plus tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pyprobeplus.parser import ParserBase, ProbePlusData +import pytest + +from homeassistant.components.probe_plus.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.probe_plus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="FM210 aa:bb:cc:dd:ee:ff", + domain=DOMAIN, + version=1, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + + +@pytest.fixture +def mock_probe_plus() -> MagicMock: + """Mock the Probe Plus device.""" + with patch( + "homeassistant.components.probe_plus.coordinator.ProbePlusDevice", + autospec=True, + ) as mock_device: + device = mock_device.return_value + device.connected = True + device.name = "FM210 aa:bb:cc:dd:ee:ff" + mock_state = ParserBase() + mock_state.state = ProbePlusData( + relay_battery=50, + probe_battery=50, + probe_temperature=25.0, + probe_rssi=200, + probe_voltage=3.7, + relay_status=1, + relay_voltage=9.0, + ) + device._device_state = mock_state + yield device diff --git a/tests/components/probe_plus/test_config_flow.py b/tests/components/probe_plus/test_config_flow.py new file mode 100644 index 00000000000..1d248144311 --- /dev/null +++ b/tests/components/probe_plus/test_config_flow.py @@ -0,0 +1,133 @@ +"""Test the config flow for the Probe Plus.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.probe_plus.const import DOMAIN +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +service_info = BluetoothServiceInfo( + name="FM210", + address="aa:bb:cc:dd:ee:ff", + rssi=-63, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", +) + + +@pytest.fixture +def mock_discovered_service_info() -> Generator[AsyncMock]: + """Override getting Bluetooth service info.""" + with patch( + "homeassistant.components.probe_plus.config_flow.async_discovered_service_info", + return_value=[service_info], + ) as mock_discovered_service_info: + yield mock_discovered_service_info + + +async def test_user_config_flow_creates_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test the user configuration flow successfully creates a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + assert result["title"] == "FM210 aa:bb:cc:dd:ee:ff" + assert result["data"] == {CONF_ADDRESS: "aa:bb:cc:dd:ee:ff"} + + +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_discovered_service_info: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test that the user flow aborts when the entry is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + # this aborts with no devices found as the config flow + # already checks for existing config entries when validating the discovered devices + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_bluetooth_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test we can discover a device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "FM210 aa:bb:cc:dd:ee:ff" + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + assert result["data"] == { + CONF_ADDRESS: service_info.address, + } + + +async def test_already_configured_bluetooth_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure configure device is not discovered again.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_no_bluetooth_devices( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test flow aborts on unsupported device.""" + mock_discovered_service_info.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found"