Add Keymitt BLE integration (#76575)

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/78924/head
spycle 2022-09-22 02:44:37 +01:00 committed by GitHub
parent bbb5d6772c
commit 0e0318dc53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 788 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
},
)

View File

@ -0,0 +1,4 @@
"""Constants for Keymitt BLE."""
# Base component constants
DOMAIN = "keymitt_ble"
MANUFACTURER = "Naran/Keymitt"

View File

@ -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)

View File

@ -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

View File

@ -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"]
}

View File

@ -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"

View File

@ -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%]"
}
}
}

View File

@ -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)

View File

@ -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"
}
}
}
}

View File

@ -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*",

View File

@ -190,6 +190,7 @@ FLOWS = {
"kaleidescape",
"keenetic_ndms2",
"kegtron",
"keymitt_ble",
"kmtronic",
"knx",
"kodi",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,8 @@
"""Define fixtures available for all tests."""
import pytest
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""

View File

@ -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