Add Keymitt BLE integration (#76575)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
bbb5d6772c
commit
0e0318dc53
19 changed files with 788 additions and 0 deletions
|
@ -626,6 +626,11 @@ omit =
|
||||||
homeassistant/components/kef/*
|
homeassistant/components/kef/*
|
||||||
homeassistant/components/keyboard/*
|
homeassistant/components/keyboard/*
|
||||||
homeassistant/components/keyboard_remote/*
|
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/kira/*
|
||||||
homeassistant/components/kiwi/lock.py
|
homeassistant/components/kiwi/lock.py
|
||||||
homeassistant/components/kodi/__init__.py
|
homeassistant/components/kodi/__init__.py
|
||||||
|
|
|
@ -580,6 +580,8 @@ build.json @home-assistant/supervisor
|
||||||
/homeassistant/components/kegtron/ @Ernst79
|
/homeassistant/components/kegtron/ @Ernst79
|
||||||
/tests/components/kegtron/ @Ernst79
|
/tests/components/kegtron/ @Ernst79
|
||||||
/homeassistant/components/keyboard_remote/ @bendavid @lanrat
|
/homeassistant/components/keyboard_remote/ @bendavid @lanrat
|
||||||
|
/homeassistant/components/keymitt_ble/ @spycle
|
||||||
|
/tests/components/keymitt_ble/ @spycle
|
||||||
/homeassistant/components/kmtronic/ @dgomes
|
/homeassistant/components/kmtronic/ @dgomes
|
||||||
/tests/components/kmtronic/ @dgomes
|
/tests/components/kmtronic/ @dgomes
|
||||||
/homeassistant/components/knx/ @Julius2342 @farmio @marvin-w
|
/homeassistant/components/knx/ @Julius2342 @farmio @marvin-w
|
||||||
|
|
50
homeassistant/components/keymitt_ble/__init__.py
Normal file
50
homeassistant/components/keymitt_ble/__init__.py
Normal 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
|
157
homeassistant/components/keymitt_ble/config_flow.py
Normal file
157
homeassistant/components/keymitt_ble/config_flow.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
4
homeassistant/components/keymitt_ble/const.py
Normal file
4
homeassistant/components/keymitt_ble/const.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
"""Constants for Keymitt BLE."""
|
||||||
|
# Base component constants
|
||||||
|
DOMAIN = "keymitt_ble"
|
||||||
|
MANUFACTURER = "Naran/Keymitt"
|
56
homeassistant/components/keymitt_ble/coordinator.py
Normal file
56
homeassistant/components/keymitt_ble/coordinator.py
Normal 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)
|
39
homeassistant/components/keymitt_ble/entity.py
Normal file
39
homeassistant/components/keymitt_ble/entity.py
Normal 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
|
22
homeassistant/components/keymitt_ble/manifest.json
Normal file
22
homeassistant/components/keymitt_ble/manifest.json
Normal 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"]
|
||||||
|
}
|
46
homeassistant/components/keymitt_ble/services.yaml
Normal file
46
homeassistant/components/keymitt_ble/services.yaml
Normal 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"
|
27
homeassistant/components/keymitt_ble/strings.json
Normal file
27
homeassistant/components/keymitt_ble/strings.json
Normal 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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
70
homeassistant/components/keymitt_ble/switch.py
Normal file
70
homeassistant/components/keymitt_ble/switch.py
Normal 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)
|
27
homeassistant/components/keymitt_ble/translations/en.json
Normal file
27
homeassistant/components/keymitt_ble/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -153,6 +153,18 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
|
||||||
"connectable": False,
|
"connectable": False,
|
||||||
"manufacturer_id": 65535,
|
"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",
|
"domain": "led_ble",
|
||||||
"local_name": "LEDnet*",
|
"local_name": "LEDnet*",
|
||||||
|
|
|
@ -190,6 +190,7 @@ FLOWS = {
|
||||||
"kaleidescape",
|
"kaleidescape",
|
||||||
"keenetic_ndms2",
|
"keenetic_ndms2",
|
||||||
"kegtron",
|
"kegtron",
|
||||||
|
"keymitt_ble",
|
||||||
"kmtronic",
|
"kmtronic",
|
||||||
"knx",
|
"knx",
|
||||||
"kodi",
|
"kodi",
|
||||||
|
|
|
@ -22,6 +22,9 @@ PyFlick==0.0.2
|
||||||
# homeassistant.components.mvglive
|
# homeassistant.components.mvglive
|
||||||
PyMVGLive==1.1.4
|
PyMVGLive==1.1.4
|
||||||
|
|
||||||
|
# homeassistant.components.keymitt_ble
|
||||||
|
PyMicroBot==0.0.6
|
||||||
|
|
||||||
# homeassistant.components.mobile_app
|
# homeassistant.components.mobile_app
|
||||||
# homeassistant.components.owntracks
|
# homeassistant.components.owntracks
|
||||||
PyNaCl==1.5.0
|
PyNaCl==1.5.0
|
||||||
|
|
|
@ -18,6 +18,9 @@ HAP-python==4.5.0
|
||||||
# homeassistant.components.flick_electric
|
# homeassistant.components.flick_electric
|
||||||
PyFlick==0.0.2
|
PyFlick==0.0.2
|
||||||
|
|
||||||
|
# homeassistant.components.keymitt_ble
|
||||||
|
PyMicroBot==0.0.6
|
||||||
|
|
||||||
# homeassistant.components.mobile_app
|
# homeassistant.components.mobile_app
|
||||||
# homeassistant.components.owntracks
|
# homeassistant.components.owntracks
|
||||||
PyNaCl==1.5.0
|
PyNaCl==1.5.0
|
||||||
|
|
83
tests/components/keymitt_ble/__init__.py
Normal file
83
tests/components/keymitt_ble/__init__.py
Normal 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
|
8
tests/components/keymitt_ble/conftest.py
Normal file
8
tests/components/keymitt_ble/conftest.py
Normal 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."""
|
173
tests/components/keymitt_ble/test_config_flow.py
Normal file
173
tests/components/keymitt_ble/test_config_flow.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue