Kulersky refactor to new Bluetooth subsystem (#142309)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Emily Love Watson 2025-04-14 08:38:34 -05:00 committed by GitHub
parent bc683ce6ee
commit d44d07ffcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 495 additions and 176 deletions

View File

@ -291,6 +291,7 @@ homeassistant.components.kaleidescape.*
homeassistant.components.knocki.* homeassistant.components.knocki.*
homeassistant.components.knx.* homeassistant.components.knx.*
homeassistant.components.kraken.* homeassistant.components.kraken.*
homeassistant.components.kulersky.*
homeassistant.components.lacrosse.* homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.* homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.* homeassistant.components.lamarzocco.*

View File

@ -1,21 +1,31 @@
"""Kuler Sky lights integration.""" """Kuler Sky lights integration."""
from homeassistant.config_entries import ConfigEntry import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
PLATFORMS = [Platform.LIGHT] PLATFORMS = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Kuler Sky from a config entry.""" """Set up Kuler Sky from a config entry."""
if DOMAIN not in hass.data: ble_device = async_ble_device_from_address(
hass.data[DOMAIN] = {} hass, entry.data[CONF_ADDRESS], connectable=True
if DATA_ADDRESSES not in hass.data[DOMAIN]: )
hass.data[DOMAIN][DATA_ADDRESSES] = set() if not ble_device:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
@ -23,11 +33,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
# Stop discovery
unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
if unregister_discovery:
unregister_discovery()
hass.data.pop(DOMAIN, None)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
# Version 1 was a single entry instance that started a bluetooth discovery
# thread to add devices. Version 2 has one config entry per device, and
# supports core bluetooth discovery
if config_entry.version == 1:
dev_reg = dr.async_get(hass)
devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
if len(devices) == 0:
_LOGGER.error("Unable to migrate; No devices registered")
return False
first_device = devices[0]
domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
address = next(iter(domain_identifiers))[1]
hass.config_entries.async_update_entry(
config_entry,
title=first_device.name or address,
data={CONF_ADDRESS: address},
unique_id=address,
version=2,
)
# Create new config flows for the remaining devices
for device in devices[1:]:
domain_identifiers = [i for i in device.identifiers if i[0] == DOMAIN]
address = next(iter(domain_identifiers))[1]
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data={CONF_ADDRESS: address},
)
)
_LOGGER.debug("Migration to version %s successful", config_entry.version)
return True

View File

@ -1,26 +1,143 @@
"""Config flow for Kuler Sky.""" """Config flow for Kuler Sky."""
import logging import logging
from typing import Any
from bluetooth_data_tools import human_readable_name
import pykulersky import pykulersky
import voluptuous as vol
from homeassistant.core import HomeAssistant from homeassistant.components.bluetooth import (
from homeassistant.helpers import config_entry_flow BluetoothServiceInfoBleak,
async_discovered_service_info,
async_last_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN from .const import DOMAIN, EXPECTED_SERVICE_UUID
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def _async_has_devices(hass: HomeAssistant) -> bool: class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Return if there are devices that can be discovered.""" """Handle a config flow for Kulersky."""
# Check if there are any devices that can be discovered in the network.
VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_integration_discovery(
self, discovery_info: dict[str, str]
) -> ConfigFlowResult:
"""Handle the integration discovery step.
The old version of the integration used to have multiple
device in a single config entry. This is now deprecated.
The integration discovery step is used to create config
entries for each device beyond the first one.
"""
address: str = discovery_info[CONF_ADDRESS]
if service_info := async_last_service_info(self.hass, address):
title = human_readable_name(None, service_info.name, service_info.address)
else:
title = address
await self.async_set_unique_id(address)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: address},
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {
"name": human_readable_name(
None, discovery_info.name, discovery_info.address
)
}
return await self.async_step_user()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input[CONF_ADDRESS]
discovery_info = self._discovered_devices[address]
local_name = human_readable_name(
None, discovery_info.name, discovery_info.address
)
await self.async_set_unique_id(
discovery_info.address, raise_on_progress=False
)
self._abort_if_unique_id_configured()
kulersky_light = None
try: try:
devices = await pykulersky.discover() kulersky_light = pykulersky.Light(discovery_info.address)
except pykulersky.PykulerskyException as exc: await kulersky_light.connect()
_LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc) except pykulersky.PykulerskyException:
return False errors["base"] = "cannot_connect"
return len(devices) > 0 except Exception:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=local_name,
data={
CONF_ADDRESS: discovery_info.address,
},
)
finally:
if kulersky_light:
await kulersky_light.disconnect()
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids()
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.address in self._discovered_devices
or EXPECTED_SERVICE_UUID not in discovery.service_uuids
):
continue
self._discovered_devices[discovery.address] = discovery
config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices) if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
if self._discovery_info:
data_schema = vol.Schema(
{vol.Required(CONF_ADDRESS): self._discovery_info.address}
)
else:
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: (
f"{service_info.name} ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)

View File

@ -4,3 +4,5 @@ DOMAIN = "kulersky"
DATA_ADDRESSES = "addresses" DATA_ADDRESSES = "addresses"
DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription" DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription"
EXPECTED_SERVICE_UUID = "8d96a001-0002-64c2-0001-9acc4838521c"

View File

@ -2,12 +2,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
import pykulersky import pykulersky
from homeassistant.components import bluetooth
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_RGBW_COLOR, ATTR_RGBW_COLOR,
@ -15,18 +15,15 @@ from homeassistant.components.light import (
LightEntity, LightEntity,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DISCOVERY_INTERVAL = timedelta(seconds=60)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -34,32 +31,15 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Kuler sky light devices.""" """Set up Kuler sky light devices."""
ble_device = bluetooth.async_ble_device_from_address(
async def discover(*args): hass, config_entry.data[CONF_ADDRESS], connectable=True
"""Attempt to discover new lights."""
lights = await pykulersky.discover()
# Filter out already discovered lights
new_lights = [
light
for light in lights
if light.address not in hass.data[DOMAIN][DATA_ADDRESSES]
]
new_entities = []
for light in new_lights:
hass.data[DOMAIN][DATA_ADDRESSES].add(light.address)
new_entities.append(KulerskyLight(light))
async_add_entities(new_entities, update_before_add=True)
# Start initial discovery
hass.async_create_task(discover())
# Perform recurring discovery of new devices
hass.data[DOMAIN][DATA_DISCOVERY_SUBSCRIPTION] = async_track_time_interval(
hass, discover, DISCOVERY_INTERVAL
) )
entity = KulerskyLight(
config_entry.title,
config_entry.data[CONF_ADDRESS],
pykulersky.Light(ble_device),
)
async_add_entities([entity], update_before_add=True)
class KulerskyLight(LightEntity): class KulerskyLight(LightEntity):
@ -71,37 +51,30 @@ class KulerskyLight(LightEntity):
_attr_supported_color_modes = {ColorMode.RGBW} _attr_supported_color_modes = {ColorMode.RGBW}
_attr_color_mode = ColorMode.RGBW _attr_color_mode = ColorMode.RGBW
def __init__(self, light: pykulersky.Light) -> None: def __init__(self, name: str, address: str, light: pykulersky.Light) -> None:
"""Initialize a Kuler Sky light.""" """Initialize a Kuler Sky light."""
self._light = light self._light = light
self._attr_unique_id = light.address self._attr_unique_id = address
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, light.address)}, identifiers={(DOMAIN, address)},
connections={(CONNECTION_BLUETOOTH, address)},
manufacturer="Brightech", manufacturer="Brightech",
name=light.name, name=name,
) )
async def async_added_to_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Run when entity about to be added to hass."""
self.async_on_remove(
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, self.async_will_remove_from_hass
)
)
async def async_will_remove_from_hass(self, *args) -> None:
"""Run when entity will be removed from hass.""" """Run when entity will be removed from hass."""
try: try:
await self._light.disconnect() await self._light.disconnect()
except pykulersky.PykulerskyException: except pykulersky.PykulerskyException:
_LOGGER.debug( _LOGGER.debug(
"Exception disconnected from %s", self._light.address, exc_info=True "Exception disconnected from %s", self._attr_unique_id, exc_info=True
) )
@property @property
def is_on(self): def is_on(self) -> bool | None:
"""Return true if light is on.""" """Return true if light is on."""
return self.brightness > 0 return self.brightness is not None and self.brightness > 0
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on.""" """Instruct the light to turn on."""
@ -133,11 +106,13 @@ class KulerskyLight(LightEntity):
rgbw = await self._light.get_color() rgbw = await self._light.get_color()
except pykulersky.PykulerskyException as exc: except pykulersky.PykulerskyException as exc:
if self._attr_available: if self._attr_available:
_LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc) _LOGGER.warning(
"Unable to connect to %s: %s", self._attr_unique_id, exc
)
self._attr_available = False self._attr_available = False
return return
if self._attr_available is False: if self._attr_available is False:
_LOGGER.warning("Reconnected to %s", self._light.address) _LOGGER.info("Reconnected to %s", self._attr_unique_id)
self._attr_available = True self._attr_available = True
brightness = max(rgbw) brightness = max(rgbw)

View File

@ -1,8 +1,14 @@
{ {
"domain": "kulersky", "domain": "kulersky",
"name": "Kuler Sky", "name": "Kuler Sky",
"bluetooth": [
{
"service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c"
}
],
"codeowners": ["@emlove"], "codeowners": ["@emlove"],
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/kulersky", "documentation": "https://www.home-assistant.io/integrations/kulersky",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bleak", "pykulersky"], "loggers": ["bleak", "pykulersky"],

View File

@ -1,13 +1,23 @@
{ {
"config": { "config": {
"step": { "step": {
"confirm": { "user": {
"description": "[%key:common::config_flow::description::confirm_setup%]" "data": {
"address": "[%key:common::config_flow::data::device%]"
}
} }
}, },
"abort": { "abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"exceptions": {
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
} }
} }
} }

View File

@ -404,6 +404,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "keymitt_ble", "domain": "keymitt_ble",
"local_name": "mib*", "local_name": "mib*",
}, },
{
"domain": "kulersky",
"service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c",
},
{ {
"domain": "lamarzocco", "domain": "lamarzocco",
"local_name": "MICRA_*", "local_name": "MICRA_*",

10
mypy.ini generated
View File

@ -2666,6 +2666,16 @@ disallow_untyped_defs = true
warn_return_any = true warn_return_any = true
warn_unreachable = true warn_unreachable = true
[mypy-homeassistant.components.kulersky.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lacrosse.*] [mypy-homeassistant.components.lacrosse.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

View File

@ -1,105 +1,182 @@
"""Test the Kuler Sky config flow.""" """Test the Kuler Sky config flow."""
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, Mock, patch
import pykulersky import pykulersky
from homeassistant import config_entries from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.components.kulersky.config_flow import DOMAIN from homeassistant.components.kulersky.config_flow import DOMAIN
from homeassistant.config_entries import (
SOURCE_BLUETOOTH,
SOURCE_INTEGRATION_DISCOVERY,
SOURCE_USER,
)
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
async def test_flow_success(hass: HomeAssistant) -> None: KULERSKY_SERVICE_INFO = BluetoothServiceInfoBleak(
"""Test we get the form.""" name="KulerLight",
manufacturer_data={},
service_data={},
service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="KulerLight",
manufacturer_data={},
service_data={},
service_uuids=["8d96a001-0002-64c2-0001-9acc4838521c"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "KulerLight"),
time=0,
connectable=True,
tx_power=-127,
)
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=KULERSKY_SERVICE_INFO,
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["errors"] is None assert result["step_id"] == "user"
light = MagicMock(spec=pykulersky.Light) with patch("pykulersky.Light", Mock(return_value=AsyncMock())):
light.address = "AA:BB:CC:11:22:33" result = await hass.config_entries.flow.async_configure(
light.name = "Bedroom"
with (
patch(
"homeassistant.components.kulersky.config_flow.pykulersky.discover",
return_value=[light],
),
patch(
"homeassistant.components.kulersky.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{}, {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Kuler Sky" assert result["title"] == "KulerLight (EEFF)"
assert result2["data"] == {} assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
assert len(mock_setup_entry.mock_calls) == 1 }
async def test_flow_no_devices_found(hass: HomeAssistant) -> None: async def test_integration_discovery(hass: HomeAssistant) -> None:
"""Test we get the form.""" """Test discovery via bluetooth with a valid device."""
with patch(
"homeassistant.components.kulersky.config_flow.async_last_service_info",
return_value=KULERSKY_SERVICE_INFO,
):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "KulerLight (EEFF)"
assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
}
async def test_integration_discovery_no_last_service_info(hass: HomeAssistant) -> None:
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "AA:BB:CC:DD:EE:FF"
assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
}
async def test_user_setup(hass: HomeAssistant) -> None:
"""Test the user manually setting up the integration."""
with patch(
"homeassistant.components.kulersky.config_flow.async_discovered_service_info",
return_value=[
KULERSKY_SERVICE_INFO,
KULERSKY_SERVICE_INFO,
], # Pass twice to test duplicate logic
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["errors"] is None assert result["step_id"] == "user"
with ( with patch("pykulersky.Light", Mock(return_value=AsyncMock())):
patch( result = await hass.config_entries.flow.async_configure(
"homeassistant.components.kulersky.config_flow.pykulersky.discover", result["flow_id"],
{CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "KulerLight (EEFF)"
assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
}
async def test_user_setup_no_devices(hass: HomeAssistant) -> None:
"""Test the user manually setting up the integration."""
with patch(
"homeassistant.components.kulersky.config_flow.async_discovered_service_info",
return_value=[], return_value=[],
),
patch(
"homeassistant.components.kulersky.async_setup_entry",
return_value=True,
) as mock_setup_entry,
): ):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "no_devices_found"
assert len(mock_setup_entry.mock_calls) == 0
async def test_flow_exceptions_caught(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_connection_error(hass: HomeAssistant) -> None:
"""Test a connection error trying to set up."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=KULERSKY_SERVICE_INFO,
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["errors"] is None assert result["step_id"] == "user"
with ( with patch("pykulersky.Light", Mock(side_effect=pykulersky.PykulerskyException)):
patch( result = await hass.config_entries.flow.async_configure(
"homeassistant.components.kulersky.config_flow.pykulersky.discover",
side_effect=pykulersky.PykulerskyException("TEST"),
),
patch(
"homeassistant.components.kulersky.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{}, {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.FORM
assert result2["reason"] == "no_devices_found" assert result["errors"]["base"] == "cannot_connect"
assert len(mock_setup_entry.mock_calls) == 0
async def test_unexpected_error(hass: HomeAssistant) -> None:
"""Test an unexpected error trying to set up."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=KULERSKY_SERVICE_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
with patch("pykulersky.Light", Mock(side_effect=Exception)):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == "unknown"

View File

@ -0,0 +1,65 @@
"""Tests for init methods."""
from homeassistant.components.kulersky.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
async def test_migrate_entry(
hass: HomeAssistant,
) -> None:
"""Test migrate config entry from v1 to v2."""
mock_config_entry_v1 = MockConfigEntry(
version=1,
domain=DOMAIN,
title="KulerSky",
)
mock_config_entry_v1.add_to_hass(hass)
dev_reg = dr.async_get(hass)
# Create device registry entries for old integration
dev_reg.async_get_or_create(
config_entry_id=mock_config_entry_v1.entry_id,
identifiers={(DOMAIN, "AA:BB:CC:11:22:33")},
name="KuLight 1",
)
dev_reg.async_get_or_create(
config_entry_id=mock_config_entry_v1.entry_id,
identifiers={(DOMAIN, "AA:BB:CC:44:55:66")},
name="KuLight 2",
)
await hass.config_entries.async_setup(mock_config_entry_v1.entry_id)
await hass.async_block_till_done()
assert mock_config_entry_v1.state is ConfigEntryState.SETUP_RETRY
assert mock_config_entry_v1.version == 2
assert mock_config_entry_v1.unique_id == "AA:BB:CC:11:22:33"
assert mock_config_entry_v1.data == {
CONF_ADDRESS: "AA:BB:CC:11:22:33",
}
async def test_migrate_entry_no_devices_found(
hass: HomeAssistant,
) -> None:
"""Test migrate config entry from v1 to v2."""
mock_config_entry_v1 = MockConfigEntry(
version=1,
domain=DOMAIN,
title="KulerSky",
)
mock_config_entry_v1.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_v1.entry_id)
await hass.async_block_till_done()
assert mock_config_entry_v1.state is ConfigEntryState.MIGRATION_ERROR
assert mock_config_entry_v1.version == 1

View File

@ -1,16 +1,13 @@
"""Test the Kuler Sky lights.""" """Test the Kuler Sky lights."""
from collections.abc import AsyncGenerator from collections.abc import Generator
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from bleak.backends.device import BLEDevice
import pykulersky import pykulersky
import pytest import pytest
from homeassistant.components.kulersky.const import ( from homeassistant.components.kulersky.const import DOMAIN
DATA_ADDRESSES,
DATA_DISCOVERY_SUBSCRIPTION,
DOMAIN,
)
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_MODE, ATTR_COLOR_MODE,
@ -26,6 +23,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME, ATTR_FRIENDLY_NAME,
ATTR_SUPPORTED_FEATURES, ATTR_SUPPORTED_FEATURES,
CONF_ADDRESS,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
@ -37,26 +35,43 @@ from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
def mock_ble_device() -> Generator[MagicMock]:
"""Mock BLEDevice."""
with patch(
"homeassistant.components.kulersky.async_ble_device_from_address",
return_value=BLEDevice(
address="AA:BB:CC:11:22:33", name="Bedroom", rssi=-50, details={}
),
) as ble_device:
yield ble_device
@pytest.fixture @pytest.fixture
async def mock_entry() -> MockConfigEntry: async def mock_entry() -> MockConfigEntry:
"""Create a mock light entity.""" """Create a mock light entity."""
return MockConfigEntry(domain=DOMAIN) return MockConfigEntry(
domain=DOMAIN,
data={CONF_ADDRESS: "AA:BB:CC:11:22:33"},
title="Bedroom",
version=2,
)
@pytest.fixture @pytest.fixture
async def mock_light( async def mock_light(
hass: HomeAssistant, mock_entry: MockConfigEntry hass: HomeAssistant, mock_entry: MockConfigEntry, mock_ble_device: MagicMock
) -> AsyncGenerator[MagicMock]: ) -> Generator[AsyncMock]:
"""Create a mock light entity.""" """Mock pykulersky light."""
light = AsyncMock()
light = MagicMock(spec=pykulersky.Light)
light.address = "AA:BB:CC:11:22:33" light.address = "AA:BB:CC:11:22:33"
light.name = "Bedroom" light.name = "Bedroom"
light.connect.return_value = True light.connect.return_value = True
light.get_color.return_value = (0, 0, 0, 0) light.get_color.return_value = (0, 0, 0, 0)
with patch( with patch(
"homeassistant.components.kulersky.light.pykulersky.discover", "pykulersky.Light",
return_value=[light], return_value=light,
): ):
mock_entry.add_to_hass(hass) mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id) await hass.config_entries.async_setup(mock_entry.entry_id)
@ -67,7 +82,7 @@ async def mock_light(
yield light yield light
async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None: async def test_init(hass: HomeAssistant, mock_light: AsyncMock) -> None:
"""Test platform setup.""" """Test platform setup."""
state = hass.states.get("light.bedroom") state = hass.states.get("light.bedroom")
assert state.state == STATE_OFF assert state.state == STATE_OFF
@ -83,24 +98,14 @@ async def test_init(hass: HomeAssistant, mock_light: MagicMock) -> None:
ATTR_RGBW_COLOR: None, ATTR_RGBW_COLOR: None,
} }
with patch.object(hass.loop, "stop"):
await hass.async_stop()
await hass.async_block_till_done()
assert mock_light.disconnect.called
async def test_remove_entry( async def test_remove_entry(
hass: HomeAssistant, mock_light: MagicMock, mock_entry: MockConfigEntry hass: HomeAssistant, mock_light: MagicMock, mock_entry: MockConfigEntry
) -> None: ) -> None:
"""Test platform setup.""" """Test platform setup."""
assert hass.data[DOMAIN][DATA_ADDRESSES] == {"AA:BB:CC:11:22:33"}
assert DATA_DISCOVERY_SUBSCRIPTION in hass.data[DOMAIN]
await hass.config_entries.async_remove(mock_entry.entry_id) await hass.config_entries.async_remove(mock_entry.entry_id)
assert mock_light.disconnect.called assert mock_light.disconnect.called
assert DOMAIN not in hass.data
async def test_remove_entry_exceptions_caught( async def test_remove_entry_exceptions_caught(