From 0e0318dc53120f2f0fc00f32a05e62132bd8bc69 Mon Sep 17 00:00:00 2001 From: spycle <48740594+spycle@users.noreply.github.com> Date: Thu, 22 Sep 2022 02:44:37 +0100 Subject: [PATCH] Add Keymitt BLE integration (#76575) Co-authored-by: J. Nick Koston --- .coveragerc | 5 + CODEOWNERS | 2 + .../components/keymitt_ble/__init__.py | 50 +++++ .../components/keymitt_ble/config_flow.py | 157 ++++++++++++++++ homeassistant/components/keymitt_ble/const.py | 4 + .../components/keymitt_ble/coordinator.py | 56 ++++++ .../components/keymitt_ble/entity.py | 39 ++++ .../components/keymitt_ble/manifest.json | 22 +++ .../components/keymitt_ble/services.yaml | 46 +++++ .../components/keymitt_ble/strings.json | 27 +++ .../components/keymitt_ble/switch.py | 70 +++++++ .../keymitt_ble/translations/en.json | 27 +++ homeassistant/generated/bluetooth.py | 12 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/keymitt_ble/__init__.py | 83 +++++++++ tests/components/keymitt_ble/conftest.py | 8 + .../keymitt_ble/test_config_flow.py | 173 ++++++++++++++++++ 19 files changed, 788 insertions(+) create mode 100644 homeassistant/components/keymitt_ble/__init__.py create mode 100644 homeassistant/components/keymitt_ble/config_flow.py create mode 100644 homeassistant/components/keymitt_ble/const.py create mode 100644 homeassistant/components/keymitt_ble/coordinator.py create mode 100644 homeassistant/components/keymitt_ble/entity.py create mode 100644 homeassistant/components/keymitt_ble/manifest.json create mode 100644 homeassistant/components/keymitt_ble/services.yaml create mode 100644 homeassistant/components/keymitt_ble/strings.json create mode 100644 homeassistant/components/keymitt_ble/switch.py create mode 100644 homeassistant/components/keymitt_ble/translations/en.json create mode 100644 tests/components/keymitt_ble/__init__.py create mode 100644 tests/components/keymitt_ble/conftest.py create mode 100644 tests/components/keymitt_ble/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 352abd7bb7a..9aafc4300cb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -626,6 +626,11 @@ omit = homeassistant/components/kef/* homeassistant/components/keyboard/* homeassistant/components/keyboard_remote/* + homeassistant/components/keymitt_ble/__init__.py + homeassistant/components/keymitt_ble/const.py + homeassistant/components/keymitt_ble/entity.py + homeassistant/components/keymitt_ble/switch.py + homeassistant/components/keymitt_ble/coordinator.py homeassistant/components/kira/* homeassistant/components/kiwi/lock.py homeassistant/components/kodi/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 156a66b174f..1e3a06f16c7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -580,6 +580,8 @@ build.json @home-assistant/supervisor /homeassistant/components/kegtron/ @Ernst79 /tests/components/kegtron/ @Ernst79 /homeassistant/components/keyboard_remote/ @bendavid @lanrat +/homeassistant/components/keymitt_ble/ @spycle +/tests/components/keymitt_ble/ @spycle /homeassistant/components/kmtronic/ @dgomes /tests/components/kmtronic/ @dgomes /homeassistant/components/knx/ @Julius2342 @farmio @marvin-w diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py new file mode 100644 index 00000000000..1a7df4fe0a9 --- /dev/null +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -0,0 +1,50 @@ +"""Integration to integrate Keymitt BLE devices with Home Assistant.""" +from __future__ import annotations + +import logging + +from microbot import MicroBotApiClient + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import MicroBotDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__package__) +PLATFORMS: list[str] = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + hass.data.setdefault(DOMAIN, {}) + token: str = entry.data[CONF_ACCESS_TOKEN] + bdaddr: str = entry.data[CONF_ADDRESS] + ble_device = bluetooth.async_ble_device_from_address(hass, bdaddr) + if not ble_device: + raise ConfigEntryNotReady(f"Could not find MicroBot with address {bdaddr}") + client = MicroBotApiClient( + device=ble_device, + token=token, + ) + coordinator = MicroBotDataUpdateCoordinator( + hass, client=client, ble_device=ble_device + ) + + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(coordinator.async_start()) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an 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/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py new file mode 100644 index 00000000000..8a8a954abd6 --- /dev/null +++ b/homeassistant/components/keymitt_ble/config_flow.py @@ -0,0 +1,157 @@ +"""Adds config flow for MicroBot.""" +from __future__ import annotations + +import logging +from typing import Any + +from bleak.backends.device import BLEDevice +from microbot import ( + MicroBotAdvertisement, + MicroBotApiClient, + parse_advertisement_data, + randomid, +) +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +def short_address(address: str) -> str: + """Convert a Bluetooth address to a short address.""" + results = address.replace("-", ":").split(":") + return f"{results[0].upper()}{results[1].upper()}"[0:4] + + +def name_from_discovery(discovery: MicroBotAdvertisement) -> str: + """Get the name from a discovery.""" + return f'{discovery.data["local_name"]} {short_address(discovery.address)}' + + +class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for MicroBot.""" + + VERSION = 1 + + def __init__(self): + """Initialize.""" + self._errors = {} + self._discovered_adv: MicroBotAdvertisement | None = None + self._discovered_advs: dict[str, MicroBotAdvertisement] = {} + self._client: MicroBotApiClient | None = None + self._ble_device: BLEDevice | None = None + self._name: str | None = None + self._bdaddr: str | None = None + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered bluetooth device: %s", discovery_info) + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._ble_device = discovery_info.device + parsed = parse_advertisement_data( + discovery_info.device, discovery_info.advertisement + ) + self._discovered_adv = parsed + self.context["title_placeholders"] = { + "name": name_from_discovery(self._discovered_adv), + } + return await self.async_step_init() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + # This is for backwards compatibility. + return await self.async_step_init(user_input) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Check if paired.""" + errors: dict[str, str] = {} + + if discovery := self._discovered_adv: + self._discovered_advs[discovery.address] = discovery + else: + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + self._ble_device = discovery_info.device + address = discovery_info.address + if address in current_addresses or address in self._discovered_advs: + continue + parsed = parse_advertisement_data( + discovery_info.device, discovery_info.advertisement + ) + if parsed: + self._discovered_adv = parsed + self._discovered_advs[address] = parsed + + if not self._discovered_advs: + return self.async_abort(reason="no_unconfigured_devices") + + if user_input is not None: + self._name = name_from_discovery(self._discovered_adv) + self._bdaddr = user_input[CONF_ADDRESS] + await self.async_set_unique_id(self._bdaddr, raise_on_progress=False) + self._abort_if_unique_id_configured() + return await self.async_step_link() + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + address: f"{parsed.data['local_name']} ({address})" + for address, parsed in self._discovered_advs.items() + } + ) + } + ), + errors=errors, + ) + + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Given a configured host, will ask the user to press the button to pair.""" + errors: dict[str, str] = {} + token = randomid(32) + self._client = MicroBotApiClient( + device=self._ble_device, + token=token, + ) + assert self._client is not None + if user_input is None: + await self._client.connect(init=True) + return self.async_show_form(step_id="link") + + if not self._client.is_connected(): + errors["base"] = "linking" + else: + await self._client.disconnect() + + if errors: + return self.async_show_form(step_id="link", errors=errors) + + assert self._name is not None + return self.async_create_entry( + title=self._name, + data=user_input + | { + CONF_ADDRESS: self._bdaddr, + CONF_ACCESS_TOKEN: token, + }, + ) diff --git a/homeassistant/components/keymitt_ble/const.py b/homeassistant/components/keymitt_ble/const.py new file mode 100644 index 00000000000..a10e7124226 --- /dev/null +++ b/homeassistant/components/keymitt_ble/const.py @@ -0,0 +1,4 @@ +"""Constants for Keymitt BLE.""" +# Base component constants +DOMAIN = "keymitt_ble" +MANUFACTURER = "Naran/Keymitt" diff --git a/homeassistant/components/keymitt_ble/coordinator.py b/homeassistant/components/keymitt_ble/coordinator.py new file mode 100644 index 00000000000..e3a995e3813 --- /dev/null +++ b/homeassistant/components/keymitt_ble/coordinator.py @@ -0,0 +1,56 @@ +"""Integration to integrate Keymitt BLE devices with Home Assistant.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from microbot import MicroBotApiClient, parse_advertisement_data + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.passive_update_coordinator import ( + PassiveBluetoothDataUpdateCoordinator, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback + +if TYPE_CHECKING: + from bleak.backends.device import BLEDevice + +_LOGGER: logging.Logger = logging.getLogger(__package__) +PLATFORMS: list[str] = [Platform.SWITCH] + + +class MicroBotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): + """Class to manage fetching data from the MicroBot.""" + + def __init__( + self, + hass: HomeAssistant, + client: MicroBotApiClient, + ble_device: BLEDevice, + ) -> None: + """Initialize.""" + self.api: MicroBotApiClient = client + self.data: dict[str, Any] = {} + self.ble_device = ble_device + super().__init__( + hass, + _LOGGER, + ble_device.address, + bluetooth.BluetoothScanningMode.ACTIVE, + ) + + @callback + def _async_handle_bluetooth_event( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + if adv := parse_advertisement_data( + service_info.device, service_info.advertisement + ): + self.data = adv.data + _LOGGER.debug("%s: MicroBot data: %s", self.ble_device.address, self.data) + self.api.update_from_advertisement(adv) + super()._async_handle_bluetooth_event(service_info, change) diff --git a/homeassistant/components/keymitt_ble/entity.py b/homeassistant/components/keymitt_ble/entity.py new file mode 100644 index 00000000000..dcda4a94027 --- /dev/null +++ b/homeassistant/components/keymitt_ble/entity.py @@ -0,0 +1,39 @@ +"""MicroBot class.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.bluetooth.passive_update_coordinator import ( + PassiveBluetoothCoordinatorEntity, +) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo + +from .const import MANUFACTURER + +if TYPE_CHECKING: + from . import MicroBotDataUpdateCoordinator + + +class MicroBotEntity(PassiveBluetoothCoordinatorEntity): + """Generic entity for all MicroBots.""" + + coordinator: MicroBotDataUpdateCoordinator + + def __init__(self, coordinator, config_entry): + """Initialise the entity.""" + super().__init__(coordinator) + self._address = self.coordinator.ble_device.address + self._attr_name = "Push" + self._attr_unique_id = self._address + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_BLUETOOTH, self._address)}, + manufacturer=MANUFACTURER, + model="Push", + name="MicroBot", + ) + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data for this entity.""" + return self.coordinator.data diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json new file mode 100644 index 00000000000..445a2581bda --- /dev/null +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -0,0 +1,22 @@ +{ + "domain": "keymitt_ble", + "name": "Keymitt MicroBot Push", + "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", + "config_flow": true, + "bluetooth": [ + { + "service_uuid": "00001831-0000-1000-8000-00805f9b34fb" + }, + { + "service_data_uuid": "00001831-0000-1000-8000-00805f9b34fb" + }, + { + "local_name": "mib*" + } + ], + "codeowners": ["@spycle"], + "requirements": ["PyMicroBot==0.0.6"], + "iot_class": "assumed_state", + "dependencies": ["bluetooth"], + "loggers": ["keymitt_ble"] +} diff --git a/homeassistant/components/keymitt_ble/services.yaml b/homeassistant/components/keymitt_ble/services.yaml new file mode 100644 index 00000000000..c611577eb26 --- /dev/null +++ b/homeassistant/components/keymitt_ble/services.yaml @@ -0,0 +1,46 @@ +calibrate: + name: Calibrate + description: Calibration - Set depth, press & hold duration, and operation mode. Warning - this will send a push command to the device + fields: + entity_id: + name: Entity + description: Name of entity to calibrate + selector: + entity: + integration: keymitt_ble + domain: switch + depth: + name: Depth + description: Depth in percent + example: 50 + required: true + selector: + number: + mode: slider + step: 1 + min: 0 + max: 100 + unit_of_measurement: "%" + duration: + name: Duration + description: Duration in seconds + example: 1 + required: true + selector: + number: + mode: box + step: 1 + min: 0 + max: 60 + unit_of_measurement: seconds + mode: + name: Mode + description: normal | invert | toggle + example: "normal" + required: true + selector: + select: + options: + - "normal" + - "invert" + - "toggle" diff --git a/homeassistant/components/keymitt_ble/strings.json b/homeassistant/components/keymitt_ble/strings.json new file mode 100644 index 00000000000..3914a2f9a30 --- /dev/null +++ b/homeassistant/components/keymitt_ble/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "init": { + "title": "Setup MicroBot device", + "data": { + "address": "Device address", + "name": "Name" + } + }, + "link": { + "title": "Pairing", + "description": "Press the button on the MicroBot Push when the LED is solid pink or green to register with Home Assistant." + } + }, + "error": { + "linking": "Failed to pair, please try again. Is the MicroBot in pairing mode?" + }, + "abort": { + "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", + "no_unconfigured_devices": "No unconfigured devices found.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py new file mode 100644 index 00000000000..92decea53ca --- /dev/null +++ b/homeassistant/components/keymitt_ble/switch.py @@ -0,0 +1,70 @@ +"""Switch platform for MicroBot.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_platform + +from .const import DOMAIN +from .entity import MicroBotEntity + +if TYPE_CHECKING: + from . import MicroBotDataUpdateCoordinator + +CALIBRATE = "calibrate" +CALIBRATE_SCHEMA = { + vol.Required("depth"): cv.positive_int, + vol.Required("duration"): cv.positive_int, + vol.Required("mode"): vol.In(["normal", "invert", "toggle"]), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up MicroBot based on a config entry.""" + coordinator: MicroBotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([MicroBotBinarySwitch(coordinator, entry)]) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + CALIBRATE, + CALIBRATE_SCHEMA, + "async_calibrate", + ) + + +class MicroBotBinarySwitch(MicroBotEntity, SwitchEntity): + """MicroBot switch class.""" + + _attr_has_entity_name = True + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.coordinator.api.push_on() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.coordinator.api.push_off() + self.async_write_ha_state() + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self.coordinator.api.is_on + + async def async_calibrate( + self, + depth: int, + duration: int, + mode: str, + ) -> None: + """Send calibration commands to the switch.""" + await self.coordinator.api.calibrate(depth, duration, mode) diff --git a/homeassistant/components/keymitt_ble/translations/en.json b/homeassistant/components/keymitt_ble/translations/en.json new file mode 100644 index 00000000000..ca5fa547770 --- /dev/null +++ b/homeassistant/components/keymitt_ble/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured_device": "Device is already configured", + "cannot_connect": "Failed to connect", + "no_unconfigured_devices": "No unconfigured devices found.", + "unknown": "Unexpected error" + }, + "error": { + "linking": "Failed to pair, please try again. Is the MicroBot in pairing mode?" + }, + "flow_title": "{name}", + "step": { + "init": { + "data": { + "address": "Device address", + "name": "Name" + }, + "title": "Setup MicroBot device" + }, + "link": { + "description": "Press the button on the MicroBot Push when the LED is solid pink or green to register with Home Assistant.", + "title": "Pairing" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 3ba5883a70c..861960b1afd 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -153,6 +153,18 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "connectable": False, "manufacturer_id": 65535, }, + { + "domain": "keymitt_ble", + "service_uuid": "00001831-0000-1000-8000-00805f9b34fb", + }, + { + "domain": "keymitt_ble", + "service_data_uuid": "00001831-0000-1000-8000-00805f9b34fb", + }, + { + "domain": "keymitt_ble", + "local_name": "mib*", + }, { "domain": "led_ble", "local_name": "LEDnet*", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 993503c678a..a66e426c883 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -190,6 +190,7 @@ FLOWS = { "kaleidescape", "keenetic_ndms2", "kegtron", + "keymitt_ble", "kmtronic", "knx", "kodi", diff --git a/requirements_all.txt b/requirements_all.txt index f9d73a9922b..204540ebdff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,6 +22,9 @@ PyFlick==0.0.2 # homeassistant.components.mvglive PyMVGLive==1.1.4 +# homeassistant.components.keymitt_ble +PyMicroBot==0.0.6 + # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a45ada504ff..ef4f9f4e38f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -18,6 +18,9 @@ HAP-python==4.5.0 # homeassistant.components.flick_electric PyFlick==0.0.2 +# homeassistant.components.keymitt_ble +PyMicroBot==0.0.6 + # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.5.0 diff --git a/tests/components/keymitt_ble/__init__.py b/tests/components/keymitt_ble/__init__.py new file mode 100644 index 00000000000..0b145970643 --- /dev/null +++ b/tests/components/keymitt_ble/__init__.py @@ -0,0 +1,83 @@ +"""Tests for the MicroBot integration.""" +from unittest.mock import patch + +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.const import CONF_ADDRESS + +DOMAIN = "keymitt_ble" + +ENTRY_CONFIG = { + CONF_ADDRESS: "e7:89:43:99:99:99", +} + +USER_INPUT = { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", +} + +USER_INPUT_INVALID = { + CONF_ADDRESS: "invalid-mac", +} + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.keymitt_ble.async_setup_entry", + return_value=return_value, + ) + + +SERVICE_INFO = BluetoothServiceInfoBleak( + name="mibp", + service_uuids=["00001831-0000-1000-8000-00805f9b34fb"], + address="aa:bb:cc:dd:ee:ff", + manufacturer_data={}, + service_data={}, + rssi=-60, + source="local", + advertisement=AdvertisementData( + local_name="mibp", + manufacturer_data={}, + service_uuids=["00001831-0000-1000-8000-00805f9b34fb"], + ), + device=BLEDevice("aa:bb:cc:dd:ee:ff", "mibp"), + time=0, + connectable=True, +) + + +class MockMicroBotApiClient: + """Mock MicroBotApiClient.""" + + def __init__(self, device, token): + """Mock init.""" + + async def connect(self, init): + """Mock connect.""" + + async def disconnect(self): + """Mock disconnect.""" + + def is_connected(self): + """Mock connected.""" + return True + + +class MockMicroBotApiClientFail: + """Mock MicroBotApiClient.""" + + def __init__(self, device, token): + """Mock init.""" + + async def connect(self, init): + """Mock connect.""" + + async def disconnect(self): + """Mock disconnect.""" + + def is_connected(self): + """Mock disconnected.""" + return False diff --git a/tests/components/keymitt_ble/conftest.py b/tests/components/keymitt_ble/conftest.py new file mode 100644 index 00000000000..3df082c4361 --- /dev/null +++ b/tests/components/keymitt_ble/conftest.py @@ -0,0 +1,8 @@ +"""Define fixtures available for all tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/keymitt_ble/test_config_flow.py b/tests/components/keymitt_ble/test_config_flow.py new file mode 100644 index 00000000000..81e6a0be8e7 --- /dev/null +++ b/tests/components/keymitt_ble/test_config_flow.py @@ -0,0 +1,173 @@ +"""Test the MicroBot config flow.""" + +from unittest.mock import ANY, patch + +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + SERVICE_INFO, + USER_INPUT, + MockMicroBotApiClient, + MockMicroBotApiClientFail, + patch_async_setup_entry, +) + +from tests.common import MockConfigEntry + +DOMAIN = "keymitt_ble" + + +async def test_bluetooth_discovery(hass): + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=SERVICE_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + with patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_bluetooth_discovery_already_setup(hass): + """Test discovery via bluetooth with a valid device when already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_setup(hass): + """Test the user initiated form with valid mac.""" + + with patch( + "homeassistant.components.keymitt_ble.config_flow.async_discovered_service_info", + return_value=[SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "link" + assert result2["errors"] is None + + with patch( + "homeassistant.components.keymitt_ble.config_flow.MicroBotApiClient", + MockMicroBotApiClient, + ), patch_async_setup_entry() as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["result"].data == { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_ACCESS_TOKEN: ANY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_setup_already_configured(hass): + """Test the user initiated form with valid mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.keymitt_ble.config_flow.async_discovered_service_info", + return_value=[SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_unconfigured_devices" + + +async def test_user_no_devices(hass): + """Test the user initiated form with valid mac.""" + with patch( + "homeassistant.components.keymitt_ble.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_unconfigured_devices" + + +async def test_no_link(hass): + """Test the user initiated form with invalid response.""" + + with patch( + "homeassistant.components.keymitt_ble.config_flow.async_discovered_service_info", + return_value=[SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "link" + with patch( + "homeassistant.components.keymitt_ble.config_flow.MicroBotApiClient", + MockMicroBotApiClientFail, + ), patch_async_setup_entry() as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "link" + assert result3["errors"] == {"base": "linking"} + + assert len(mock_setup_entry.mock_calls) == 0