mirror of
https://github.com/home-assistant/core.git
synced 2025-04-26 18:27:51 +00:00
Add Crownstone integration (#50677)
This commit is contained in:
parent
bac55b78fe
commit
2a51bb5bba
@ -171,6 +171,13 @@ omit =
|
|||||||
homeassistant/components/coolmaster/const.py
|
homeassistant/components/coolmaster/const.py
|
||||||
homeassistant/components/cppm_tracker/device_tracker.py
|
homeassistant/components/cppm_tracker/device_tracker.py
|
||||||
homeassistant/components/cpuspeed/sensor.py
|
homeassistant/components/cpuspeed/sensor.py
|
||||||
|
homeassistant/components/crownstone/__init__.py
|
||||||
|
homeassistant/components/crownstone/const.py
|
||||||
|
homeassistant/components/crownstone/listeners.py
|
||||||
|
homeassistant/components/crownstone/helpers.py
|
||||||
|
homeassistant/components/crownstone/devices.py
|
||||||
|
homeassistant/components/crownstone/entry_manager.py
|
||||||
|
homeassistant/components/crownstone/light.py
|
||||||
homeassistant/components/cups/sensor.py
|
homeassistant/components/cups/sensor.py
|
||||||
homeassistant/components/currencylayer/sensor.py
|
homeassistant/components/currencylayer/sensor.py
|
||||||
homeassistant/components/daikin/*
|
homeassistant/components/daikin/*
|
||||||
|
@ -27,6 +27,7 @@ homeassistant.components.calendar.*
|
|||||||
homeassistant.components.camera.*
|
homeassistant.components.camera.*
|
||||||
homeassistant.components.canary.*
|
homeassistant.components.canary.*
|
||||||
homeassistant.components.cover.*
|
homeassistant.components.cover.*
|
||||||
|
homeassistant.components.crownstone.*
|
||||||
homeassistant.components.device_automation.*
|
homeassistant.components.device_automation.*
|
||||||
homeassistant.components.device_tracker.*
|
homeassistant.components.device_tracker.*
|
||||||
homeassistant.components.devolo_home_control.*
|
homeassistant.components.devolo_home_control.*
|
||||||
|
@ -104,6 +104,7 @@ homeassistant/components/coronavirus/* @home-assistant/core
|
|||||||
homeassistant/components/counter/* @fabaff
|
homeassistant/components/counter/* @fabaff
|
||||||
homeassistant/components/cover/* @home-assistant/core
|
homeassistant/components/cover/* @home-assistant/core
|
||||||
homeassistant/components/cpuspeed/* @fabaff
|
homeassistant/components/cpuspeed/* @fabaff
|
||||||
|
homeassistant/components/crownstone/* @Crownstone @RicArch97
|
||||||
homeassistant/components/cups/* @fabaff
|
homeassistant/components/cups/* @fabaff
|
||||||
homeassistant/components/daikin/* @fredrike
|
homeassistant/components/daikin/* @fredrike
|
||||||
homeassistant/components/darksky/* @fabaff
|
homeassistant/components/darksky/* @fabaff
|
||||||
|
23
homeassistant/components/crownstone/__init__.py
Normal file
23
homeassistant/components/crownstone/__init__.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""Integration for Crownstone."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .entry_manager import CrownstoneEntryManager
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Initiate setup for a Crownstone config entry."""
|
||||||
|
manager = CrownstoneEntryManager(hass, entry)
|
||||||
|
|
||||||
|
return await manager.async_setup()
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok: bool = await hass.data[DOMAIN][entry.entry_id].async_unload()
|
||||||
|
if len(hass.data[DOMAIN]) == 0:
|
||||||
|
hass.data.pop(DOMAIN)
|
||||||
|
return unload_ok
|
299
homeassistant/components/crownstone/config_flow.py
Normal file
299
homeassistant/components/crownstone/config_flow.py
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
"""Flow handler for Crownstone."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crownstone_cloud import CrownstoneCloud
|
||||||
|
from crownstone_cloud.exceptions import (
|
||||||
|
CrownstoneAuthenticationError,
|
||||||
|
CrownstoneUnknownError,
|
||||||
|
)
|
||||||
|
import serial.tools.list_ports
|
||||||
|
from serial.tools.list_ports_common import ListPortInfo
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import usb
|
||||||
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||||
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers import aiohttp_client
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_USB_MANUAL_PATH,
|
||||||
|
CONF_USB_PATH,
|
||||||
|
CONF_USB_SPHERE,
|
||||||
|
CONF_USB_SPHERE_OPTION,
|
||||||
|
CONF_USE_USB_OPTION,
|
||||||
|
DOMAIN,
|
||||||
|
DONT_USE_USB,
|
||||||
|
MANUAL_PATH,
|
||||||
|
REFRESH_LIST,
|
||||||
|
)
|
||||||
|
from .entry_manager import CrownstoneEntryManager
|
||||||
|
from .helpers import list_ports_as_str
|
||||||
|
|
||||||
|
|
||||||
|
class CrownstoneConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Crownstone."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
cloud: CrownstoneCloud
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
) -> CrownstoneOptionsFlowHandler:
|
||||||
|
"""Return the Crownstone options."""
|
||||||
|
return CrownstoneOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the flow."""
|
||||||
|
self.login_info: dict[str, Any] = {}
|
||||||
|
self.usb_path: str | None = None
|
||||||
|
self.usb_sphere_id: str | None = None
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cloud = CrownstoneCloud(
|
||||||
|
email=user_input[CONF_EMAIL],
|
||||||
|
password=user_input[CONF_PASSWORD],
|
||||||
|
clientsession=aiohttp_client.async_get_clientsession(self.hass),
|
||||||
|
)
|
||||||
|
# Login & sync all user data
|
||||||
|
try:
|
||||||
|
await self.cloud.async_initialize()
|
||||||
|
except CrownstoneAuthenticationError as auth_error:
|
||||||
|
if auth_error.type == "LOGIN_FAILED":
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
elif auth_error.type == "LOGIN_FAILED_EMAIL_NOT_VERIFIED":
|
||||||
|
errors["base"] = "account_not_verified"
|
||||||
|
except CrownstoneUnknownError:
|
||||||
|
errors["base"] = "unknown_error"
|
||||||
|
|
||||||
|
# show form again, with the errors
|
||||||
|
if errors:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.async_set_unique_id(self.cloud.cloud_data.user_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
self.login_info = user_input
|
||||||
|
return await self.async_step_usb_config()
|
||||||
|
|
||||||
|
async def async_step_usb_config(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Set up a Crownstone USB dongle."""
|
||||||
|
list_of_ports = await self.hass.async_add_executor_job(
|
||||||
|
serial.tools.list_ports.comports
|
||||||
|
)
|
||||||
|
ports_as_string = list_ports_as_str(list_of_ports)
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
selection = user_input[CONF_USB_PATH]
|
||||||
|
|
||||||
|
if selection == DONT_USE_USB:
|
||||||
|
return self.async_create_new_entry()
|
||||||
|
if selection == MANUAL_PATH:
|
||||||
|
return await self.async_step_usb_manual_config()
|
||||||
|
if selection != REFRESH_LIST:
|
||||||
|
selected_port: ListPortInfo = list_of_ports[
|
||||||
|
(ports_as_string.index(selection) - 1)
|
||||||
|
]
|
||||||
|
self.usb_path = await self.hass.async_add_executor_job(
|
||||||
|
usb.get_serial_by_id, selected_port.device
|
||||||
|
)
|
||||||
|
return await self.async_step_usb_sphere_config()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="usb_config",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{vol.Required(CONF_USB_PATH): vol.In(ports_as_string)}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_usb_manual_config(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Manually enter Crownstone USB dongle path."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="usb_manual_config",
|
||||||
|
data_schema=vol.Schema({vol.Required(CONF_USB_MANUAL_PATH): str}),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.usb_path = user_input[CONF_USB_MANUAL_PATH]
|
||||||
|
return await self.async_step_usb_sphere_config()
|
||||||
|
|
||||||
|
async def async_step_usb_sphere_config(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Select a Crownstone sphere that the USB operates in."""
|
||||||
|
spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data}
|
||||||
|
# no need to select if there's only 1 option
|
||||||
|
sphere_id: str | None = None
|
||||||
|
if len(spheres) == 1:
|
||||||
|
sphere_id = next(iter(spheres.values()))
|
||||||
|
|
||||||
|
if user_input is None and sphere_id is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="usb_sphere_config",
|
||||||
|
data_schema=vol.Schema({CONF_USB_SPHERE: vol.In(spheres.keys())}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sphere_id:
|
||||||
|
self.usb_sphere_id = sphere_id
|
||||||
|
elif user_input:
|
||||||
|
self.usb_sphere_id = spheres[user_input[CONF_USB_SPHERE]]
|
||||||
|
|
||||||
|
return self.async_create_new_entry()
|
||||||
|
|
||||||
|
def async_create_new_entry(self) -> FlowResult:
|
||||||
|
"""Create a new entry."""
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=f"Account: {self.login_info[CONF_EMAIL]}",
|
||||||
|
data={
|
||||||
|
CONF_EMAIL: self.login_info[CONF_EMAIL],
|
||||||
|
CONF_PASSWORD: self.login_info[CONF_PASSWORD],
|
||||||
|
},
|
||||||
|
options={CONF_USB_PATH: self.usb_path, CONF_USB_SPHERE: self.usb_sphere_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CrownstoneOptionsFlowHandler(OptionsFlow):
|
||||||
|
"""Handle Crownstone options."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize Crownstone options."""
|
||||||
|
self.entry = config_entry
|
||||||
|
self.updated_options = config_entry.options.copy()
|
||||||
|
self.spheres: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def async_step_init(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Manage Crownstone options."""
|
||||||
|
manager: CrownstoneEntryManager = self.hass.data[DOMAIN][self.entry.entry_id]
|
||||||
|
|
||||||
|
spheres = {sphere.name: sphere.cloud_id for sphere in manager.cloud.cloud_data}
|
||||||
|
usb_path = self.entry.options.get(CONF_USB_PATH)
|
||||||
|
usb_sphere = self.entry.options.get(CONF_USB_SPHERE)
|
||||||
|
|
||||||
|
options_schema = vol.Schema(
|
||||||
|
{vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool}
|
||||||
|
)
|
||||||
|
if usb_path is not None and len(spheres) > 1:
|
||||||
|
options_schema = options_schema.extend(
|
||||||
|
{
|
||||||
|
vol.Optional(
|
||||||
|
CONF_USB_SPHERE_OPTION,
|
||||||
|
default=manager.cloud.cloud_data.spheres[usb_sphere].name,
|
||||||
|
): vol.In(spheres.keys())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
if user_input[CONF_USE_USB_OPTION] and usb_path is None:
|
||||||
|
self.spheres = spheres
|
||||||
|
return await self.async_step_usb_config_option()
|
||||||
|
if not user_input[CONF_USE_USB_OPTION] and usb_path is not None:
|
||||||
|
self.updated_options[CONF_USB_PATH] = None
|
||||||
|
self.updated_options[CONF_USB_SPHERE] = None
|
||||||
|
elif (
|
||||||
|
CONF_USB_SPHERE_OPTION in user_input
|
||||||
|
and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere
|
||||||
|
):
|
||||||
|
sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]]
|
||||||
|
user_input[CONF_USB_SPHERE_OPTION] = sphere_id
|
||||||
|
self.updated_options[CONF_USB_SPHERE] = sphere_id
|
||||||
|
|
||||||
|
return self.async_create_entry(title="", data=self.updated_options)
|
||||||
|
|
||||||
|
return self.async_show_form(step_id="init", data_schema=options_schema)
|
||||||
|
|
||||||
|
async def async_step_usb_config_option(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Set up a Crownstone USB dongle."""
|
||||||
|
list_of_ports = await self.hass.async_add_executor_job(
|
||||||
|
serial.tools.list_ports.comports
|
||||||
|
)
|
||||||
|
ports_as_string = list_ports_as_str(list_of_ports, False)
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
selection = user_input[CONF_USB_PATH]
|
||||||
|
|
||||||
|
if selection == MANUAL_PATH:
|
||||||
|
return await self.async_step_usb_manual_config_option()
|
||||||
|
if selection != REFRESH_LIST:
|
||||||
|
selected_port: ListPortInfo = list_of_ports[
|
||||||
|
ports_as_string.index(selection)
|
||||||
|
]
|
||||||
|
usb_path = await self.hass.async_add_executor_job(
|
||||||
|
usb.get_serial_by_id, selected_port.device
|
||||||
|
)
|
||||||
|
self.updated_options[CONF_USB_PATH] = usb_path
|
||||||
|
return await self.async_step_usb_sphere_config_option()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="usb_config_option",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{vol.Required(CONF_USB_PATH): vol.In(ports_as_string)}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_usb_manual_config_option(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Manually enter Crownstone USB dongle path."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="usb_manual_config_option",
|
||||||
|
data_schema=vol.Schema({vol.Required(CONF_USB_MANUAL_PATH): str}),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.updated_options[CONF_USB_PATH] = user_input[CONF_USB_MANUAL_PATH]
|
||||||
|
return await self.async_step_usb_sphere_config_option()
|
||||||
|
|
||||||
|
async def async_step_usb_sphere_config_option(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Select a Crownstone sphere that the USB operates in."""
|
||||||
|
# no need to select if there's only 1 option
|
||||||
|
sphere_id: str | None = None
|
||||||
|
if len(self.spheres) == 1:
|
||||||
|
sphere_id = next(iter(self.spheres.values()))
|
||||||
|
|
||||||
|
if user_input is None and sphere_id is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="usb_sphere_config_option",
|
||||||
|
data_schema=vol.Schema({CONF_USB_SPHERE: vol.In(self.spheres.keys())}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sphere_id:
|
||||||
|
self.updated_options[CONF_USB_SPHERE] = sphere_id
|
||||||
|
elif user_input:
|
||||||
|
self.updated_options[CONF_USB_SPHERE] = self.spheres[
|
||||||
|
user_input[CONF_USB_SPHERE]
|
||||||
|
]
|
||||||
|
|
||||||
|
return self.async_create_entry(title="", data=self.updated_options)
|
45
homeassistant/components/crownstone/const.py
Normal file
45
homeassistant/components/crownstone/const.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""Constants for the crownstone integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
# Platforms
|
||||||
|
DOMAIN: Final = "crownstone"
|
||||||
|
PLATFORMS: Final[list[str]] = ["light"]
|
||||||
|
|
||||||
|
# Listeners
|
||||||
|
SSE_LISTENERS: Final = "sse_listeners"
|
||||||
|
UART_LISTENERS: Final = "uart_listeners"
|
||||||
|
|
||||||
|
# Unique ID suffixes
|
||||||
|
CROWNSTONE_SUFFIX: Final = "crownstone"
|
||||||
|
|
||||||
|
# Signals (within integration)
|
||||||
|
SIG_CROWNSTONE_STATE_UPDATE: Final = "crownstone.crownstone_state_update"
|
||||||
|
SIG_CROWNSTONE_UPDATE: Final = "crownstone.crownstone_update"
|
||||||
|
SIG_UART_STATE_CHANGE: Final = "crownstone.uart_state_change"
|
||||||
|
|
||||||
|
# Abilities state
|
||||||
|
ABILITY_STATE: Final[dict[bool, str]] = {True: "Enabled", False: "Disabled"}
|
||||||
|
|
||||||
|
# Config flow
|
||||||
|
CONF_USB_PATH: Final = "usb_path"
|
||||||
|
CONF_USB_MANUAL_PATH: Final = "usb_manual_path"
|
||||||
|
CONF_USB_SPHERE: Final = "usb_sphere"
|
||||||
|
# Options flow
|
||||||
|
CONF_USE_USB_OPTION: Final = "use_usb_option"
|
||||||
|
CONF_USB_SPHERE_OPTION: Final = "usb_sphere_option"
|
||||||
|
# USB config list entries
|
||||||
|
DONT_USE_USB: Final = "Don't use USB"
|
||||||
|
REFRESH_LIST: Final = "Refresh list"
|
||||||
|
MANUAL_PATH: Final = "Enter manually"
|
||||||
|
|
||||||
|
# Crownstone entity
|
||||||
|
CROWNSTONE_INCLUDE_TYPES: Final[dict[str, str]] = {
|
||||||
|
"PLUG": "Plug",
|
||||||
|
"BUILTIN": "Built-in",
|
||||||
|
"BUILTIN_ONE": "Built-in One",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Crownstone USB Dongle
|
||||||
|
CROWNSTONE_USB: Final = "CROWNSTONE_USB"
|
43
homeassistant/components/crownstone/devices.py
Normal file
43
homeassistant/components/crownstone/devices.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Base classes for Crownstone devices."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from crownstone_cloud.cloud_models.crownstones import Crownstone
|
||||||
|
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_IDENTIFIERS,
|
||||||
|
ATTR_MANUFACTURER,
|
||||||
|
ATTR_MODEL,
|
||||||
|
ATTR_NAME,
|
||||||
|
ATTR_SW_VERSION,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
|
||||||
|
from .const import CROWNSTONE_INCLUDE_TYPES, DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class CrownstoneDevice:
|
||||||
|
"""Representation of a Crownstone device."""
|
||||||
|
|
||||||
|
def __init__(self, device: Crownstone) -> None:
|
||||||
|
"""Initialize the device."""
|
||||||
|
self.device = device
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cloud_id(self) -> str:
|
||||||
|
"""
|
||||||
|
Return the unique identifier for this device.
|
||||||
|
|
||||||
|
Used as device ID and to generate unique entity ID's.
|
||||||
|
"""
|
||||||
|
return str(self.device.cloud_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
"""Return device info."""
|
||||||
|
return {
|
||||||
|
ATTR_IDENTIFIERS: {(DOMAIN, self.cloud_id)},
|
||||||
|
ATTR_NAME: self.device.name,
|
||||||
|
ATTR_MANUFACTURER: "Crownstone",
|
||||||
|
ATTR_MODEL: CROWNSTONE_INCLUDE_TYPES[self.device.type],
|
||||||
|
ATTR_SW_VERSION: self.device.sw_version,
|
||||||
|
}
|
190
homeassistant/components/crownstone/entry_manager.py
Normal file
190
homeassistant/components/crownstone/entry_manager.py
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"""Manager to set up IO with Crownstone devices for a config entry."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from crownstone_cloud import CrownstoneCloud
|
||||||
|
from crownstone_cloud.exceptions import (
|
||||||
|
CrownstoneAuthenticationError,
|
||||||
|
CrownstoneUnknownError,
|
||||||
|
)
|
||||||
|
from crownstone_sse import CrownstoneSSEAsync
|
||||||
|
from crownstone_uart import CrownstoneUart, UartEventBus
|
||||||
|
from crownstone_uart.Exceptions import UartException
|
||||||
|
|
||||||
|
from homeassistant.components import persistent_notification
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
|
||||||
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import aiohttp_client
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_USB_PATH,
|
||||||
|
CONF_USB_SPHERE,
|
||||||
|
DOMAIN,
|
||||||
|
PLATFORMS,
|
||||||
|
SSE_LISTENERS,
|
||||||
|
UART_LISTENERS,
|
||||||
|
)
|
||||||
|
from .helpers import get_port
|
||||||
|
from .listeners import setup_sse_listeners, setup_uart_listeners
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CrownstoneEntryManager:
|
||||||
|
"""Manage a Crownstone config entry."""
|
||||||
|
|
||||||
|
uart: CrownstoneUart | None = None
|
||||||
|
cloud: CrownstoneCloud
|
||||||
|
sse: CrownstoneSSEAsync
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize the hub."""
|
||||||
|
self.hass = hass
|
||||||
|
self.config_entry = config_entry
|
||||||
|
self.listeners: dict[str, Any] = {}
|
||||||
|
self.usb_sphere_id: str | None = None
|
||||||
|
|
||||||
|
async def async_setup(self) -> bool:
|
||||||
|
"""
|
||||||
|
Set up a Crownstone config entry.
|
||||||
|
|
||||||
|
Returns True if the setup was successful.
|
||||||
|
"""
|
||||||
|
email = self.config_entry.data[CONF_EMAIL]
|
||||||
|
password = self.config_entry.data[CONF_PASSWORD]
|
||||||
|
|
||||||
|
self.cloud = CrownstoneCloud(
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
clientsession=aiohttp_client.async_get_clientsession(self.hass),
|
||||||
|
)
|
||||||
|
# Login & sync all user data
|
||||||
|
try:
|
||||||
|
await self.cloud.async_initialize()
|
||||||
|
except CrownstoneAuthenticationError as auth_err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Auth error during login with type: %s and message: %s",
|
||||||
|
auth_err.type,
|
||||||
|
auth_err.message,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except CrownstoneUnknownError as unknown_err:
|
||||||
|
_LOGGER.error("Unknown error during login")
|
||||||
|
raise ConfigEntryNotReady from unknown_err
|
||||||
|
|
||||||
|
# A new clientsession is created because the default one does not cleanup on unload
|
||||||
|
self.sse = CrownstoneSSEAsync(
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
access_token=self.cloud.access_token,
|
||||||
|
websession=aiohttp_client.async_create_clientsession(self.hass),
|
||||||
|
)
|
||||||
|
# Listen for events in the background, without task tracking
|
||||||
|
asyncio.create_task(self.async_process_events(self.sse))
|
||||||
|
setup_sse_listeners(self)
|
||||||
|
|
||||||
|
# Set up a Crownstone USB only if path exists
|
||||||
|
if self.config_entry.options[CONF_USB_PATH] is not None:
|
||||||
|
await self.async_setup_usb()
|
||||||
|
|
||||||
|
# Save the sphere where the USB is located
|
||||||
|
# Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple
|
||||||
|
self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE]
|
||||||
|
|
||||||
|
self.hass.data.setdefault(DOMAIN, {})[self.config_entry.entry_id] = self
|
||||||
|
self.hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS)
|
||||||
|
|
||||||
|
# HA specific listeners
|
||||||
|
self.config_entry.async_on_unload(
|
||||||
|
self.config_entry.add_update_listener(_async_update_listener)
|
||||||
|
)
|
||||||
|
self.config_entry.async_on_unload(
|
||||||
|
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.on_shutdown)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def async_process_events(self, sse_client: CrownstoneSSEAsync) -> None:
|
||||||
|
"""Asynchronous iteration of Crownstone SSE events."""
|
||||||
|
async with sse_client as client:
|
||||||
|
async for event in client:
|
||||||
|
if event is not None:
|
||||||
|
# Make SSE updates, like ability change, available to the user
|
||||||
|
self.hass.bus.async_fire(f"{DOMAIN}_{event.type}", event.data)
|
||||||
|
|
||||||
|
async def async_setup_usb(self) -> None:
|
||||||
|
"""Attempt setup of a Crownstone usb dongle."""
|
||||||
|
# Trace by-id symlink back to the serial port
|
||||||
|
serial_port = await self.hass.async_add_executor_job(
|
||||||
|
get_port, self.config_entry.options[CONF_USB_PATH]
|
||||||
|
)
|
||||||
|
if serial_port is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.uart = CrownstoneUart()
|
||||||
|
# UartException is raised when serial controller fails to open
|
||||||
|
try:
|
||||||
|
await self.uart.initialize_usb(serial_port)
|
||||||
|
except UartException:
|
||||||
|
self.uart = None
|
||||||
|
# Set entry options for usb to null
|
||||||
|
updated_options = self.config_entry.options.copy()
|
||||||
|
updated_options[CONF_USB_PATH] = None
|
||||||
|
updated_options[CONF_USB_SPHERE] = None
|
||||||
|
# Ensure that the user can configure an USB again from options
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.config_entry, options=updated_options
|
||||||
|
)
|
||||||
|
# Show notification to ensure the user knows the cloud is now used
|
||||||
|
persistent_notification.async_create(
|
||||||
|
self.hass,
|
||||||
|
f"Setup of Crownstone USB dongle was unsuccessful on port {serial_port}.\n \
|
||||||
|
Crownstone Cloud will be used to switch Crownstones.\n \
|
||||||
|
Please check if your port is correct and set up the USB again from integration options.",
|
||||||
|
"Crownstone",
|
||||||
|
"crownstone_usb_dongle_setup",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
setup_uart_listeners(self)
|
||||||
|
|
||||||
|
async def async_unload(self) -> bool:
|
||||||
|
"""Unload the current config entry."""
|
||||||
|
# Authentication failed
|
||||||
|
if self.cloud.cloud_data is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.sse.close_client()
|
||||||
|
for sse_unsub in self.listeners[SSE_LISTENERS]:
|
||||||
|
sse_unsub()
|
||||||
|
|
||||||
|
if self.uart:
|
||||||
|
self.uart.stop()
|
||||||
|
for subscription_id in self.listeners[UART_LISTENERS]:
|
||||||
|
UartEventBus.unsubscribe(subscription_id)
|
||||||
|
|
||||||
|
unload_ok = await self.hass.config_entries.async_unload_platforms(
|
||||||
|
self.config_entry, PLATFORMS
|
||||||
|
)
|
||||||
|
|
||||||
|
if unload_ok:
|
||||||
|
self.hass.data[DOMAIN].pop(self.config_entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def on_shutdown(self, _: Event) -> None:
|
||||||
|
"""Close all IO connections."""
|
||||||
|
self.sse.close_client()
|
||||||
|
if self.uart:
|
||||||
|
self.uart.stop()
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Handle options update."""
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
59
homeassistant/components/crownstone/helpers.py
Normal file
59
homeassistant/components/crownstone/helpers.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""Helper functions for the Crownstone integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from serial.tools.list_ports_common import ListPortInfo
|
||||||
|
|
||||||
|
from homeassistant.components import usb
|
||||||
|
|
||||||
|
from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
|
||||||
|
|
||||||
|
|
||||||
|
def list_ports_as_str(
|
||||||
|
serial_ports: list[ListPortInfo], no_usb_option: bool = True
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Represent currently available serial ports as string.
|
||||||
|
|
||||||
|
Adds option to not use usb on top of the list,
|
||||||
|
option to use manual path or refresh list at the end.
|
||||||
|
"""
|
||||||
|
ports_as_string: list[str] = []
|
||||||
|
|
||||||
|
if no_usb_option:
|
||||||
|
ports_as_string.append(DONT_USE_USB)
|
||||||
|
|
||||||
|
for port in serial_ports:
|
||||||
|
ports_as_string.append(
|
||||||
|
usb.human_readable_device_name(
|
||||||
|
port.device,
|
||||||
|
port.serial_number,
|
||||||
|
port.manufacturer,
|
||||||
|
port.description,
|
||||||
|
f"{hex(port.vid)[2:]:0>4}".upper(),
|
||||||
|
f"{hex(port.pid)[2:]:0>4}".upper(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ports_as_string.append(MANUAL_PATH)
|
||||||
|
ports_as_string.append(REFRESH_LIST)
|
||||||
|
|
||||||
|
return ports_as_string
|
||||||
|
|
||||||
|
|
||||||
|
def get_port(dev_path: str) -> str | None:
|
||||||
|
"""Get the port that the by-id link points to."""
|
||||||
|
# not a by-id link, but just given path
|
||||||
|
by_id = "/dev/serial/by-id"
|
||||||
|
if by_id not in dev_path:
|
||||||
|
return dev_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
return f"/dev/{os.path.basename(os.readlink(dev_path))}"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def map_from_to(val: int, in_min: int, in_max: int, out_min: int, out_max: int) -> int:
|
||||||
|
"""Map a value from a range to another."""
|
||||||
|
return int((val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min)
|
204
homeassistant/components/crownstone/light.py
Normal file
204
homeassistant/components/crownstone/light.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
"""Support for Crownstone devices."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from functools import partial
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from crownstone_cloud.cloud_models.crownstones import Crownstone
|
||||||
|
from crownstone_cloud.const import (
|
||||||
|
DIMMING_ABILITY,
|
||||||
|
SWITCHCRAFT_ABILITY,
|
||||||
|
TAP_TO_TOGGLE_ABILITY,
|
||||||
|
)
|
||||||
|
from crownstone_cloud.exceptions import CrownstoneAbilityError
|
||||||
|
from crownstone_uart import CrownstoneUart
|
||||||
|
|
||||||
|
from homeassistant.components.light import (
|
||||||
|
ATTR_BRIGHTNESS,
|
||||||
|
SUPPORT_BRIGHTNESS,
|
||||||
|
LightEntity,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ABILITY_STATE,
|
||||||
|
CROWNSTONE_INCLUDE_TYPES,
|
||||||
|
CROWNSTONE_SUFFIX,
|
||||||
|
DOMAIN,
|
||||||
|
SIG_CROWNSTONE_STATE_UPDATE,
|
||||||
|
SIG_UART_STATE_CHANGE,
|
||||||
|
)
|
||||||
|
from .devices import CrownstoneDevice
|
||||||
|
from .helpers import map_from_to
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .entry_manager import CrownstoneEntryManager
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up crownstones from a config entry."""
|
||||||
|
manager: CrownstoneEntryManager = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
|
||||||
|
entities: list[CrownstoneEntity] = []
|
||||||
|
|
||||||
|
# Add Crownstone entities that support switching/dimming
|
||||||
|
for sphere in manager.cloud.cloud_data:
|
||||||
|
for crownstone in sphere.crownstones:
|
||||||
|
if crownstone.type in CROWNSTONE_INCLUDE_TYPES:
|
||||||
|
# Crownstone can communicate with Crownstone USB
|
||||||
|
if manager.uart and sphere.cloud_id == manager.usb_sphere_id:
|
||||||
|
entities.append(CrownstoneEntity(crownstone, manager.uart))
|
||||||
|
# Crownstone can't communicate with Crownstone USB
|
||||||
|
else:
|
||||||
|
entities.append(CrownstoneEntity(crownstone))
|
||||||
|
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
def crownstone_state_to_hass(value: int) -> int:
|
||||||
|
"""Crownstone 0..100 to hass 0..255."""
|
||||||
|
return map_from_to(value, 0, 100, 0, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def hass_to_crownstone_state(value: int) -> int:
|
||||||
|
"""Hass 0..255 to Crownstone 0..100."""
|
||||||
|
return map_from_to(value, 0, 255, 0, 100)
|
||||||
|
|
||||||
|
|
||||||
|
class CrownstoneEntity(CrownstoneDevice, LightEntity):
|
||||||
|
"""
|
||||||
|
Representation of a crownstone.
|
||||||
|
|
||||||
|
Light platform is used to support dimming.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_icon = "mdi:power-socket-de"
|
||||||
|
|
||||||
|
def __init__(self, crownstone_data: Crownstone, usb: CrownstoneUart = None) -> None:
|
||||||
|
"""Initialize the crownstone."""
|
||||||
|
super().__init__(crownstone_data)
|
||||||
|
self.usb = usb
|
||||||
|
# Entity class attributes
|
||||||
|
self._attr_name = str(self.device.name)
|
||||||
|
self._attr_unique_id = f"{self.cloud_id}-{CROWNSTONE_SUFFIX}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usb_available(self) -> bool:
|
||||||
|
"""Return if this entity can use a usb dongle."""
|
||||||
|
return self.usb is not None and self.usb.is_ready()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int | None:
|
||||||
|
"""Return the brightness if dimming enabled."""
|
||||||
|
return crownstone_state_to_hass(self.device.state)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return if the device is on."""
|
||||||
|
return crownstone_state_to_hass(self.device.state) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features(self) -> int:
|
||||||
|
"""Return the supported features of this Crownstone."""
|
||||||
|
if self.device.abilities.get(DIMMING_ABILITY).is_enabled:
|
||||||
|
return SUPPORT_BRIGHTNESS
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||||
|
"""State attributes for Crownstone devices."""
|
||||||
|
attributes: dict[str, Any] = {}
|
||||||
|
# switch method
|
||||||
|
if self.usb_available:
|
||||||
|
attributes["switch_method"] = "Crownstone USB Dongle"
|
||||||
|
else:
|
||||||
|
attributes["switch_method"] = "Crownstone Cloud"
|
||||||
|
|
||||||
|
# crownstone abilities
|
||||||
|
attributes["dimming"] = ABILITY_STATE.get(
|
||||||
|
self.device.abilities.get(DIMMING_ABILITY).is_enabled
|
||||||
|
)
|
||||||
|
attributes["tap_to_toggle"] = ABILITY_STATE.get(
|
||||||
|
self.device.abilities.get(TAP_TO_TOGGLE_ABILITY).is_enabled
|
||||||
|
)
|
||||||
|
attributes["switchcraft"] = ABILITY_STATE.get(
|
||||||
|
self.device.abilities.get(SWITCHCRAFT_ABILITY).is_enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Set up a listener when this entity is added to HA."""
|
||||||
|
# new state received
|
||||||
|
self.async_on_remove(
|
||||||
|
async_dispatcher_connect(
|
||||||
|
self.hass, SIG_CROWNSTONE_STATE_UPDATE, self.async_write_ha_state
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# updates state attributes when usb connects/disconnects
|
||||||
|
self.async_on_remove(
|
||||||
|
async_dispatcher_connect(
|
||||||
|
self.hass, SIG_UART_STATE_CHANGE, self.async_write_ha_state
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn on this light via dongle or cloud."""
|
||||||
|
if ATTR_BRIGHTNESS in kwargs:
|
||||||
|
if self.usb_available:
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
partial(
|
||||||
|
self.usb.dim_crownstone,
|
||||||
|
self.device.unique_id,
|
||||||
|
hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await self.device.async_set_brightness(
|
||||||
|
hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS])
|
||||||
|
)
|
||||||
|
except CrownstoneAbilityError as ability_error:
|
||||||
|
_LOGGER.error(ability_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
# assume brightness is set on device
|
||||||
|
self.device.state = hass_to_crownstone_state(kwargs[ATTR_BRIGHTNESS])
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
elif self.usb_available:
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
partial(self.usb.switch_crownstone, self.device.unique_id, on=True)
|
||||||
|
)
|
||||||
|
self.device.state = 100
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.device.async_turn_on()
|
||||||
|
self.device.state = 100
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn off this device via dongle or cloud."""
|
||||||
|
if self.usb_available:
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
partial(self.usb.switch_crownstone, self.device.unique_id, on=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.device.async_turn_off()
|
||||||
|
|
||||||
|
self.device.state = 0
|
||||||
|
self.async_write_ha_state()
|
147
homeassistant/components/crownstone/listeners.py
Normal file
147
homeassistant/components/crownstone/listeners.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Listeners for updating data in the Crownstone integration.
|
||||||
|
|
||||||
|
For data updates, Cloud Push is used in form of an SSE server that sends out events.
|
||||||
|
For fast device switching Local Push is used in form of a USB dongle that hooks into a BLE mesh.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from crownstone_core.packets.serviceDataParsers.containers.AdvExternalCrownstoneState import (
|
||||||
|
AdvExternalCrownstoneState,
|
||||||
|
)
|
||||||
|
from crownstone_core.packets.serviceDataParsers.containers.elements.AdvTypes import (
|
||||||
|
AdvType,
|
||||||
|
)
|
||||||
|
from crownstone_core.protocol.SwitchState import SwitchState
|
||||||
|
from crownstone_sse.const import (
|
||||||
|
EVENT_ABILITY_CHANGE,
|
||||||
|
EVENT_ABILITY_CHANGE_DIMMING,
|
||||||
|
EVENT_SWITCH_STATE_UPDATE,
|
||||||
|
)
|
||||||
|
from crownstone_sse.events import AbilityChangeEvent, SwitchStateUpdateEvent
|
||||||
|
from crownstone_uart import UartEventBus, UartTopics
|
||||||
|
from crownstone_uart.topics.SystemTopics import SystemTopics
|
||||||
|
|
||||||
|
from homeassistant.core import Event, callback
|
||||||
|
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
SIG_CROWNSTONE_STATE_UPDATE,
|
||||||
|
SIG_UART_STATE_CHANGE,
|
||||||
|
SSE_LISTENERS,
|
||||||
|
UART_LISTENERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .entry_manager import CrownstoneEntryManager
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_update_crwn_state_sse(
|
||||||
|
manager: CrownstoneEntryManager, ha_event: Event
|
||||||
|
) -> None:
|
||||||
|
"""Update the state of a Crownstone when switched externally."""
|
||||||
|
switch_event = SwitchStateUpdateEvent(ha_event.data)
|
||||||
|
try:
|
||||||
|
updated_crownstone = manager.cloud.get_crownstone_by_id(switch_event.cloud_id)
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
|
||||||
|
# only update on change.
|
||||||
|
if updated_crownstone.state != switch_event.switch_state:
|
||||||
|
updated_crownstone.state = switch_event.switch_state
|
||||||
|
async_dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_update_crwn_ability(manager: CrownstoneEntryManager, ha_event: Event) -> None:
|
||||||
|
"""Update the ability information of a Crownstone."""
|
||||||
|
ability_event = AbilityChangeEvent(ha_event.data)
|
||||||
|
try:
|
||||||
|
updated_crownstone = manager.cloud.get_crownstone_by_id(ability_event.cloud_id)
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
|
||||||
|
ability_type = ability_event.ability_type
|
||||||
|
ability_enabled = ability_event.ability_enabled
|
||||||
|
# only update on a change in state
|
||||||
|
if updated_crownstone.abilities[ability_type].is_enabled == ability_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
# write the change to the crownstone entity.
|
||||||
|
updated_crownstone.abilities[ability_type].is_enabled = ability_enabled
|
||||||
|
|
||||||
|
if ability_event.sub_type == EVENT_ABILITY_CHANGE_DIMMING:
|
||||||
|
# reload the config entry because dimming is part of supported features
|
||||||
|
manager.hass.async_create_task(
|
||||||
|
manager.hass.config_entries.async_reload(manager.config_entry.entry_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
async_dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE)
|
||||||
|
|
||||||
|
|
||||||
|
def update_uart_state(manager: CrownstoneEntryManager, _: bool | None) -> None:
|
||||||
|
"""Update the uart ready state for entities that use USB."""
|
||||||
|
# update availability of power usage entities.
|
||||||
|
dispatcher_send(manager.hass, SIG_UART_STATE_CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
def update_crwn_state_uart(
|
||||||
|
manager: CrownstoneEntryManager, data: AdvExternalCrownstoneState
|
||||||
|
) -> None:
|
||||||
|
"""Update the state of a Crownstone when switched externally."""
|
||||||
|
if data.type != AdvType.EXTERNAL_STATE:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
updated_crownstone = manager.cloud.get_crownstone_by_uid(
|
||||||
|
data.crownstoneId, manager.usb_sphere_id
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if data.switchState is None:
|
||||||
|
return
|
||||||
|
# update on change
|
||||||
|
updated_state = cast(SwitchState, data.switchState)
|
||||||
|
if updated_crownstone.state != updated_state.intensity:
|
||||||
|
updated_crownstone.state = updated_state.intensity
|
||||||
|
|
||||||
|
dispatcher_send(manager.hass, SIG_CROWNSTONE_STATE_UPDATE)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_sse_listeners(manager: CrownstoneEntryManager) -> None:
|
||||||
|
"""Set up SSE listeners."""
|
||||||
|
# save unsub function for when entry removed
|
||||||
|
manager.listeners[SSE_LISTENERS] = [
|
||||||
|
manager.hass.bus.async_listen(
|
||||||
|
f"{DOMAIN}_{EVENT_SWITCH_STATE_UPDATE}",
|
||||||
|
partial(async_update_crwn_state_sse, manager),
|
||||||
|
),
|
||||||
|
manager.hass.bus.async_listen(
|
||||||
|
f"{DOMAIN}_{EVENT_ABILITY_CHANGE}",
|
||||||
|
partial(async_update_crwn_ability, manager),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def setup_uart_listeners(manager: CrownstoneEntryManager) -> None:
|
||||||
|
"""Set up UART listeners."""
|
||||||
|
# save subscription id to unsub
|
||||||
|
manager.listeners[UART_LISTENERS] = [
|
||||||
|
UartEventBus.subscribe(
|
||||||
|
SystemTopics.connectionEstablished,
|
||||||
|
partial(update_uart_state, manager),
|
||||||
|
),
|
||||||
|
UartEventBus.subscribe(
|
||||||
|
SystemTopics.connectionClosed,
|
||||||
|
partial(update_uart_state, manager),
|
||||||
|
),
|
||||||
|
UartEventBus.subscribe(
|
||||||
|
UartTopics.newDataAvailable,
|
||||||
|
partial(update_crwn_state_uart, manager),
|
||||||
|
),
|
||||||
|
]
|
15
homeassistant/components/crownstone/manifest.json
Normal file
15
homeassistant/components/crownstone/manifest.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"domain": "crownstone",
|
||||||
|
"name": "Crownstone",
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/crownstone",
|
||||||
|
"requirements": [
|
||||||
|
"crownstone-cloud==1.4.7",
|
||||||
|
"crownstone-sse==2.0.2",
|
||||||
|
"crownstone-uart==2.1.0",
|
||||||
|
"pyserial==3.5"
|
||||||
|
],
|
||||||
|
"codeowners": ["@Crownstone", "@RicArch97"],
|
||||||
|
"after_dependencies": ["usb"],
|
||||||
|
"iot_class": "cloud_push"
|
||||||
|
}
|
75
homeassistant/components/crownstone/strings.json
Normal file
75
homeassistant/components/crownstone/strings.json
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||||
|
"usb_setup_complete": "Crownstone USB setup complete.",
|
||||||
|
"usb_setup_unsuccessful": "Crownstone USB setup was unsuccessful."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"account_not_verified": "Account not verified. Please activate your account through the activation email from Crownstone.",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"email": "[%key:common::config_flow::data::email%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
},
|
||||||
|
"title": "Crownstone account"
|
||||||
|
},
|
||||||
|
"usb_config": {
|
||||||
|
"data": {
|
||||||
|
"usb_path": "[%key:common::config_flow::data::usb_path%]"
|
||||||
|
},
|
||||||
|
"title": "Crownstone USB dongle configuration",
|
||||||
|
"description": "Select the serial port of the Crownstone USB dongle, or select 'Don't use USB' if you don't want to setup a USB dongle.\n\nLook for a device with VID 10C4 and PID EA60."
|
||||||
|
},
|
||||||
|
"usb_manual_config": {
|
||||||
|
"data": {
|
||||||
|
"usb_manual_path": "[%key:common::config_flow::data::usb_path%]"
|
||||||
|
},
|
||||||
|
"title": "Crownstone USB dongle manual path",
|
||||||
|
"description": "Manually enter the path of a Crownstone USB dongle."
|
||||||
|
},
|
||||||
|
"usb_sphere_config": {
|
||||||
|
"data": {
|
||||||
|
"usb_sphere": "Crownstone Sphere"
|
||||||
|
},
|
||||||
|
"title": "Crownstone USB Sphere",
|
||||||
|
"description": "Select a Crownstone Sphere where the USB is located."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"use_usb_option": "Use a Crownstone USB dongle for local data transmission",
|
||||||
|
"usb_sphere_option": "Crownstone Sphere where the USB is located"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usb_config_option": {
|
||||||
|
"data": {
|
||||||
|
"usb_path": "[%key:common::config_flow::data::usb_path%]"
|
||||||
|
},
|
||||||
|
"title": "Crownstone USB dongle configuration",
|
||||||
|
"description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60."
|
||||||
|
},
|
||||||
|
"usb_manual_config_option": {
|
||||||
|
"data": {
|
||||||
|
"usb_manual_path": "[%key:common::config_flow::data::usb_path%]"
|
||||||
|
},
|
||||||
|
"title": "Crownstone USB dongle manual path",
|
||||||
|
"description": "Manually enter the path of a Crownstone USB dongle."
|
||||||
|
},
|
||||||
|
"usb_sphere_config_option": {
|
||||||
|
"data": {
|
||||||
|
"usb_sphere": "Crownstone Sphere"
|
||||||
|
},
|
||||||
|
"title": "Crownstone USB Sphere",
|
||||||
|
"description": "Select a Crownstone Sphere where the USB is located."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
75
homeassistant/components/crownstone/translations/en.json
Normal file
75
homeassistant/components/crownstone/translations/en.json
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Account is already configured",
|
||||||
|
"usb_setup_complete": "Crownstone USB setup complete.",
|
||||||
|
"usb_setup_unsuccessful": "Crownstone USB setup was unsuccessful."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"account_not_verified": "Account not verified. Please activate your account through the activation email from Crownstone.",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"usb_config": {
|
||||||
|
"data": {
|
||||||
|
"usb_path": "USB Device Path"
|
||||||
|
},
|
||||||
|
"description": "Select the serial port of the Crownstone USB dongle, or select 'Don't use USB' if you don't want to setup a USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.",
|
||||||
|
"title": "Crownstone USB dongle configuration"
|
||||||
|
},
|
||||||
|
"usb_manual_config": {
|
||||||
|
"data": {
|
||||||
|
"usb_manual_path": "USB Device Path"
|
||||||
|
},
|
||||||
|
"description": "Manually enter the path of a Crownstone USB dongle.",
|
||||||
|
"title": "Crownstone USB dongle manual path"
|
||||||
|
},
|
||||||
|
"usb_sphere_config": {
|
||||||
|
"data": {
|
||||||
|
"usb_sphere": "Crownstone Sphere"
|
||||||
|
},
|
||||||
|
"description": "Select a Crownstone Sphere where the USB is located.",
|
||||||
|
"title": "Crownstone USB Sphere"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password"
|
||||||
|
},
|
||||||
|
"title": "Crownstone account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"usb_sphere_option": "Crownstone Sphere where the USB is located",
|
||||||
|
"use_usb_option": "Use a Crownstone USB dongle for local data transmission"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"usb_config_option": {
|
||||||
|
"data": {
|
||||||
|
"usb_path": "USB Device Path"
|
||||||
|
},
|
||||||
|
"description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60.",
|
||||||
|
"title": "Crownstone USB dongle configuration"
|
||||||
|
},
|
||||||
|
"usb_manual_config_option": {
|
||||||
|
"data": {
|
||||||
|
"usb_manual_path": "USB Device Path"
|
||||||
|
},
|
||||||
|
"description": "Manually enter the path of a Crownstone USB dongle.",
|
||||||
|
"title": "Crownstone USB dongle manual path"
|
||||||
|
},
|
||||||
|
"usb_sphere_config_option": {
|
||||||
|
"data": {
|
||||||
|
"usb_sphere": "Crownstone Sphere"
|
||||||
|
},
|
||||||
|
"description": "Select a Crownstone Sphere where the USB is located.",
|
||||||
|
"title": "Crownstone USB Sphere"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -52,6 +52,7 @@ FLOWS = [
|
|||||||
"control4",
|
"control4",
|
||||||
"coolmaster",
|
"coolmaster",
|
||||||
"coronavirus",
|
"coronavirus",
|
||||||
|
"crownstone",
|
||||||
"daikin",
|
"daikin",
|
||||||
"deconz",
|
"deconz",
|
||||||
"denonavr",
|
"denonavr",
|
||||||
|
11
mypy.ini
11
mypy.ini
@ -308,6 +308,17 @@ no_implicit_optional = true
|
|||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[mypy-homeassistant.components.crownstone.*]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.device_automation.*]
|
[mypy-homeassistant.components.device_automation.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
@ -486,6 +486,15 @@ coronavirus==1.1.1
|
|||||||
# homeassistant.components.utility_meter
|
# homeassistant.components.utility_meter
|
||||||
croniter==1.0.6
|
croniter==1.0.6
|
||||||
|
|
||||||
|
# homeassistant.components.crownstone
|
||||||
|
crownstone-cloud==1.4.7
|
||||||
|
|
||||||
|
# homeassistant.components.crownstone
|
||||||
|
crownstone-sse==2.0.2
|
||||||
|
|
||||||
|
# homeassistant.components.crownstone
|
||||||
|
crownstone-uart==2.1.0
|
||||||
|
|
||||||
# homeassistant.components.datadog
|
# homeassistant.components.datadog
|
||||||
datadog==0.15.0
|
datadog==0.15.0
|
||||||
|
|
||||||
@ -1761,6 +1770,7 @@ pysensibo==1.0.3
|
|||||||
pyserial-asyncio==0.5
|
pyserial-asyncio==0.5
|
||||||
|
|
||||||
# homeassistant.components.acer_projector
|
# homeassistant.components.acer_projector
|
||||||
|
# homeassistant.components.crownstone
|
||||||
# homeassistant.components.usb
|
# homeassistant.components.usb
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
pyserial==3.5
|
pyserial==3.5
|
||||||
|
@ -285,6 +285,15 @@ coronavirus==1.1.1
|
|||||||
# homeassistant.components.utility_meter
|
# homeassistant.components.utility_meter
|
||||||
croniter==1.0.6
|
croniter==1.0.6
|
||||||
|
|
||||||
|
# homeassistant.components.crownstone
|
||||||
|
crownstone-cloud==1.4.7
|
||||||
|
|
||||||
|
# homeassistant.components.crownstone
|
||||||
|
crownstone-sse==2.0.2
|
||||||
|
|
||||||
|
# homeassistant.components.crownstone
|
||||||
|
crownstone-uart==2.1.0
|
||||||
|
|
||||||
# homeassistant.components.datadog
|
# homeassistant.components.datadog
|
||||||
datadog==0.15.0
|
datadog==0.15.0
|
||||||
|
|
||||||
@ -1026,6 +1035,7 @@ pyruckus==0.12
|
|||||||
pyserial-asyncio==0.5
|
pyserial-asyncio==0.5
|
||||||
|
|
||||||
# homeassistant.components.acer_projector
|
# homeassistant.components.acer_projector
|
||||||
|
# homeassistant.components.crownstone
|
||||||
# homeassistant.components.usb
|
# homeassistant.components.usb
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
pyserial==3.5
|
pyserial==3.5
|
||||||
|
1
tests/components/crownstone/__init__.py
Normal file
1
tests/components/crownstone/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Crownstone integration."""
|
531
tests/components/crownstone/test_config_flow.py
Normal file
531
tests/components/crownstone/test_config_flow.py
Normal file
@ -0,0 +1,531 @@
|
|||||||
|
"""Tests for the Crownstone integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from crownstone_cloud.cloud_models.spheres import Spheres
|
||||||
|
from crownstone_cloud.exceptions import (
|
||||||
|
CrownstoneAuthenticationError,
|
||||||
|
CrownstoneUnknownError,
|
||||||
|
)
|
||||||
|
import pytest
|
||||||
|
from serial.tools.list_ports_common import ListPortInfo
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.components import usb
|
||||||
|
from homeassistant.components.crownstone.const import (
|
||||||
|
CONF_USB_MANUAL_PATH,
|
||||||
|
CONF_USB_PATH,
|
||||||
|
CONF_USB_SPHERE,
|
||||||
|
CONF_USB_SPHERE_OPTION,
|
||||||
|
CONF_USE_USB_OPTION,
|
||||||
|
DOMAIN,
|
||||||
|
DONT_USE_USB,
|
||||||
|
MANUAL_PATH,
|
||||||
|
)
|
||||||
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="crownstone_setup", autouse=True)
|
||||||
|
def crownstone_setup():
|
||||||
|
"""Mock Crownstone entry setup."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.crownstone.async_setup_entry", return_value=True
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def get_mocked_crownstone_cloud(spheres: dict[str, MagicMock] | None = None):
|
||||||
|
"""Return a mocked Crownstone Cloud instance."""
|
||||||
|
mock_cloud = MagicMock()
|
||||||
|
mock_cloud.async_initialize = AsyncMock()
|
||||||
|
mock_cloud.cloud_data = Spheres(MagicMock(), "account_id")
|
||||||
|
mock_cloud.cloud_data.spheres = spheres
|
||||||
|
|
||||||
|
return mock_cloud
|
||||||
|
|
||||||
|
|
||||||
|
def create_mocked_spheres(amount: int) -> dict[str, MagicMock]:
|
||||||
|
"""Return a dict with mocked sphere instances."""
|
||||||
|
spheres: dict[str, MagicMock] = {}
|
||||||
|
for i in range(amount):
|
||||||
|
spheres[f"sphere_id_{i}"] = MagicMock()
|
||||||
|
spheres[f"sphere_id_{i}"].name = f"sphere_name_{i}"
|
||||||
|
spheres[f"sphere_id_{i}"].cloud_id = f"sphere_id_{i}"
|
||||||
|
|
||||||
|
return spheres
|
||||||
|
|
||||||
|
|
||||||
|
def get_mocked_com_port():
|
||||||
|
"""Mock of a serial port."""
|
||||||
|
port = ListPortInfo("/dev/ttyUSB1234")
|
||||||
|
port.device = "/dev/ttyUSB1234"
|
||||||
|
port.serial_number = "1234567"
|
||||||
|
port.manufacturer = "crownstone"
|
||||||
|
port.description = "crownstone dongle - crownstone dongle"
|
||||||
|
port.vid = 1234
|
||||||
|
port.pid = 5678
|
||||||
|
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def create_mocked_entry_data_conf(email: str, password: str):
|
||||||
|
"""Set a result for the entry data for comparison."""
|
||||||
|
mock_data: dict[str, str | None] = {}
|
||||||
|
mock_data[CONF_EMAIL] = email
|
||||||
|
mock_data[CONF_PASSWORD] = password
|
||||||
|
|
||||||
|
return mock_data
|
||||||
|
|
||||||
|
|
||||||
|
def create_mocked_entry_options_conf(usb_path: str | None, usb_sphere: str | None):
|
||||||
|
"""Set a result for the entry options for comparison."""
|
||||||
|
mock_options: dict[str, str | None] = {}
|
||||||
|
mock_options[CONF_USB_PATH] = usb_path
|
||||||
|
mock_options[CONF_USB_SPHERE] = usb_sphere
|
||||||
|
|
||||||
|
return mock_options
|
||||||
|
|
||||||
|
|
||||||
|
async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock):
|
||||||
|
"""Patch Crownstone Cloud and start the flow."""
|
||||||
|
mocked_login_input = {
|
||||||
|
CONF_EMAIL: "example@homeassistant.com",
|
||||||
|
CONF_PASSWORD: "homeassistantisawesome",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.crownstone.config_flow.CrownstoneCloud",
|
||||||
|
return_value=mocked_cloud,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": "user"}, data=mocked_login_input
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def start_options_flow(
|
||||||
|
hass: HomeAssistant, entry_id: str, mocked_cloud: MagicMock
|
||||||
|
):
|
||||||
|
"""Patch CrownstoneEntryManager and start the flow."""
|
||||||
|
mocked_manager = MagicMock()
|
||||||
|
mocked_manager.cloud = mocked_cloud
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][entry_id] = mocked_manager
|
||||||
|
|
||||||
|
return await hass.config_entries.options.async_init(entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_user_input(hass: HomeAssistant):
|
||||||
|
"""Test the flow done in the correct way."""
|
||||||
|
# test if a form is returned if no input is provided
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": "user"}
|
||||||
|
)
|
||||||
|
# show the login form
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_if_configured(hass: HomeAssistant):
|
||||||
|
"""Test flow with correct login input and abort if sphere already configured."""
|
||||||
|
# create mock entry conf
|
||||||
|
configured_entry_data = create_mocked_entry_data_conf(
|
||||||
|
email="example@homeassistant.com",
|
||||||
|
password="homeassistantisawesome",
|
||||||
|
)
|
||||||
|
configured_entry_options = create_mocked_entry_options_conf(
|
||||||
|
usb_path="/dev/serial/by-id/crownstone-usb",
|
||||||
|
usb_sphere="sphere_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# create mocked entry
|
||||||
|
MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=configured_entry_data,
|
||||||
|
options=configured_entry_options,
|
||||||
|
unique_id="account_id",
|
||||||
|
).add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await start_config_flow(hass, get_mocked_crownstone_cloud())
|
||||||
|
|
||||||
|
# test if we abort if we try to configure the same entry
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authentication_errors(hass: HomeAssistant):
|
||||||
|
"""Test flow with wrong auth errors."""
|
||||||
|
cloud = get_mocked_crownstone_cloud()
|
||||||
|
# side effect: auth error login failed
|
||||||
|
cloud.async_initialize.side_effect = CrownstoneAuthenticationError(
|
||||||
|
exception_type="LOGIN_FAILED"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await start_config_flow(hass, cloud)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "invalid_auth"}
|
||||||
|
|
||||||
|
# side effect: auth error account not verified
|
||||||
|
cloud.async_initialize.side_effect = CrownstoneAuthenticationError(
|
||||||
|
exception_type="LOGIN_FAILED_EMAIL_NOT_VERIFIED"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await start_config_flow(hass, cloud)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "account_not_verified"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_error(hass: HomeAssistant):
|
||||||
|
"""Test flow with unknown error."""
|
||||||
|
cloud = get_mocked_crownstone_cloud()
|
||||||
|
# side effect: unknown error
|
||||||
|
cloud.async_initialize.side_effect = CrownstoneUnknownError
|
||||||
|
|
||||||
|
result = await start_config_flow(hass, cloud)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "unknown_error"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_successful_login_no_usb(hass: HomeAssistant):
|
||||||
|
"""Test a successful login without configuring a USB."""
|
||||||
|
entry_data_without_usb = create_mocked_entry_data_conf(
|
||||||
|
email="example@homeassistant.com",
|
||||||
|
password="homeassistantisawesome",
|
||||||
|
)
|
||||||
|
entry_options_without_usb = create_mocked_entry_options_conf(
|
||||||
|
usb_path=None,
|
||||||
|
usb_sphere=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await start_config_flow(hass, get_mocked_crownstone_cloud())
|
||||||
|
# should show usb form
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_config"
|
||||||
|
|
||||||
|
# don't setup USB dongle, create entry
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_PATH: DONT_USE_USB}
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == entry_data_without_usb
|
||||||
|
assert result["options"] == entry_options_without_usb
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()])
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"homeassistant.components.usb.get_serial_by_id",
|
||||||
|
return_value="/dev/serial/by-id/crownstone-usb",
|
||||||
|
)
|
||||||
|
async def test_successful_login_with_usb(serial_mock: MagicMock, hass: HomeAssistant):
|
||||||
|
"""Test flow with correct login and usb configuration."""
|
||||||
|
entry_data_with_usb = create_mocked_entry_data_conf(
|
||||||
|
email="example@homeassistant.com",
|
||||||
|
password="homeassistantisawesome",
|
||||||
|
)
|
||||||
|
entry_options_with_usb = create_mocked_entry_options_conf(
|
||||||
|
usb_path="/dev/serial/by-id/crownstone-usb",
|
||||||
|
usb_sphere="sphere_id_1",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await start_config_flow(
|
||||||
|
hass, get_mocked_crownstone_cloud(create_mocked_spheres(2))
|
||||||
|
)
|
||||||
|
# should show usb form
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_config"
|
||||||
|
|
||||||
|
# create a mocked port
|
||||||
|
port = get_mocked_com_port()
|
||||||
|
port_select = usb.human_readable_device_name(
|
||||||
|
port.device,
|
||||||
|
port.serial_number,
|
||||||
|
port.manufacturer,
|
||||||
|
port.description,
|
||||||
|
f"{hex(port.vid)[2:]:0>4}".upper(),
|
||||||
|
f"{hex(port.pid)[2:]:0>4}".upper(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# select a port from the list
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_PATH: port_select}
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_sphere_config"
|
||||||
|
assert serial_mock.call_count == 1
|
||||||
|
|
||||||
|
# select a sphere
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_SPHERE: "sphere_name_1"}
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == entry_data_with_usb
|
||||||
|
assert result["options"] == entry_options_with_usb
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()])
|
||||||
|
)
|
||||||
|
async def test_successful_login_with_manual_usb_path(hass: HomeAssistant):
|
||||||
|
"""Test flow with correct login and usb configuration."""
|
||||||
|
entry_data_with_manual_usb = create_mocked_entry_data_conf(
|
||||||
|
email="example@homeassistant.com",
|
||||||
|
password="homeassistantisawesome",
|
||||||
|
)
|
||||||
|
entry_options_with_manual_usb = create_mocked_entry_options_conf(
|
||||||
|
usb_path="/dev/crownstone-usb",
|
||||||
|
usb_sphere="sphere_id_0",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await start_config_flow(
|
||||||
|
hass, get_mocked_crownstone_cloud(create_mocked_spheres(1))
|
||||||
|
)
|
||||||
|
# should show usb form
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_config"
|
||||||
|
|
||||||
|
# select manual from the list
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_PATH: MANUAL_PATH}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_manual_config"
|
||||||
|
|
||||||
|
# enter USB path
|
||||||
|
path = "/dev/crownstone-usb"
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_MANUAL_PATH: path}
|
||||||
|
)
|
||||||
|
|
||||||
|
# since we only have 1 sphere here, test that it's automatically selected and
|
||||||
|
# creating entry without asking for user input
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == entry_data_with_manual_usb
|
||||||
|
assert result["options"] == entry_options_with_manual_usb
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()])
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"homeassistant.components.usb.get_serial_by_id",
|
||||||
|
return_value="/dev/serial/by-id/crownstone-usb",
|
||||||
|
)
|
||||||
|
async def test_options_flow_setup_usb(serial_mock: MagicMock, hass: HomeAssistant):
|
||||||
|
"""Test options flow init."""
|
||||||
|
configured_entry_data = create_mocked_entry_data_conf(
|
||||||
|
email="example@homeassistant.com",
|
||||||
|
password="homeassistantisawesome",
|
||||||
|
)
|
||||||
|
configured_entry_options = create_mocked_entry_options_conf(
|
||||||
|
usb_path=None,
|
||||||
|
usb_sphere=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# create mocked entry
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=configured_entry_data,
|
||||||
|
options=configured_entry_options,
|
||||||
|
unique_id="account_id",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await start_options_flow(
|
||||||
|
hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(2))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
|
schema = result["data_schema"].schema
|
||||||
|
for schema_key in schema:
|
||||||
|
if schema_key == CONF_USE_USB_OPTION:
|
||||||
|
assert not schema_key.default()
|
||||||
|
|
||||||
|
# USB is not set up, so this should not be in the options
|
||||||
|
assert CONF_USB_SPHERE_OPTION not in schema
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USE_USB_OPTION: True}
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_config_option"
|
||||||
|
|
||||||
|
# create a mocked port
|
||||||
|
port = get_mocked_com_port()
|
||||||
|
port_select = usb.human_readable_device_name(
|
||||||
|
port.device,
|
||||||
|
port.serial_number,
|
||||||
|
port.manufacturer,
|
||||||
|
port.description,
|
||||||
|
f"{hex(port.vid)[2:]:0>4}".upper(),
|
||||||
|
f"{hex(port.pid)[2:]:0>4}".upper(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# select a port from the list
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_PATH: port_select}
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_sphere_config_option"
|
||||||
|
assert serial_mock.call_count == 1
|
||||||
|
|
||||||
|
# select a sphere
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_SPHERE: "sphere_name_1"}
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == create_mocked_entry_options_conf(
|
||||||
|
usb_path="/dev/serial/by-id/crownstone-usb", usb_sphere="sphere_id_1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_options_flow_remove_usb(hass: HomeAssistant):
|
||||||
|
"""Test selecting to set up an USB dongle."""
|
||||||
|
configured_entry_data = create_mocked_entry_data_conf(
|
||||||
|
email="example@homeassistant.com",
|
||||||
|
password="homeassistantisawesome",
|
||||||
|
)
|
||||||
|
configured_entry_options = create_mocked_entry_options_conf(
|
||||||
|
usb_path="/dev/serial/by-id/crownstone-usb",
|
||||||
|
usb_sphere="sphere_id_0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# create mocked entry
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=configured_entry_data,
|
||||||
|
options=configured_entry_options,
|
||||||
|
unique_id="account_id",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await start_options_flow(
|
||||||
|
hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(2))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
|
schema = result["data_schema"].schema
|
||||||
|
for schema_key in schema:
|
||||||
|
if schema_key == CONF_USE_USB_OPTION:
|
||||||
|
assert schema_key.default()
|
||||||
|
if schema_key == CONF_USB_SPHERE_OPTION:
|
||||||
|
assert schema_key.default() == "sphere_name_0"
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_USE_USB_OPTION: False,
|
||||||
|
CONF_USB_SPHERE_OPTION: "sphere_name_0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == create_mocked_entry_options_conf(
|
||||||
|
usb_path=None, usb_sphere=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"serial.tools.list_ports.comports", MagicMock(return_value=[get_mocked_com_port()])
|
||||||
|
)
|
||||||
|
async def test_options_flow_manual_usb_path(hass: HomeAssistant):
|
||||||
|
"""Test flow with correct login and usb configuration."""
|
||||||
|
configured_entry_data = create_mocked_entry_data_conf(
|
||||||
|
email="example@homeassistant.com",
|
||||||
|
password="homeassistantisawesome",
|
||||||
|
)
|
||||||
|
configured_entry_options = create_mocked_entry_options_conf(
|
||||||
|
usb_path=None,
|
||||||
|
usb_sphere=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# create mocked entry
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=configured_entry_data,
|
||||||
|
options=configured_entry_options,
|
||||||
|
unique_id="account_id",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await start_options_flow(
|
||||||
|
hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(1))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USE_USB_OPTION: True}
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_config_option"
|
||||||
|
|
||||||
|
# select manual from the list
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_PATH: MANUAL_PATH}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "usb_manual_config_option"
|
||||||
|
|
||||||
|
# enter USB path
|
||||||
|
path = "/dev/crownstone-usb"
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"], user_input={CONF_USB_MANUAL_PATH: path}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == create_mocked_entry_options_conf(
|
||||||
|
usb_path=path, usb_sphere="sphere_id_0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_options_flow_change_usb_sphere(hass: HomeAssistant):
|
||||||
|
"""Test changing the usb sphere in the options."""
|
||||||
|
configured_entry_data = create_mocked_entry_data_conf(
|
||||||
|
email="example@homeassistant.com",
|
||||||
|
password="homeassistantisawesome",
|
||||||
|
)
|
||||||
|
configured_entry_options = create_mocked_entry_options_conf(
|
||||||
|
usb_path="/dev/serial/by-id/crownstone-usb",
|
||||||
|
usb_sphere="sphere_id_0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# create mocked entry
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=configured_entry_data,
|
||||||
|
options=configured_entry_options,
|
||||||
|
unique_id="account_id",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await start_options_flow(
|
||||||
|
hass, entry.entry_id, get_mocked_crownstone_cloud(create_mocked_spheres(3))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_USE_USB_OPTION: True, CONF_USB_SPHERE_OPTION: "sphere_name_2"},
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["data"] == create_mocked_entry_options_conf(
|
||||||
|
usb_path="/dev/serial/by-id/crownstone-usb", usb_sphere="sphere_id_2"
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user