1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 02:48:57 +00:00

New integration: Hue BLE (#118635)

Co-authored-by: Mr. Bubbles <manni@zapto.de>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Harvey
2025-11-25 20:15:34 +00:00
committed by GitHub
parent 20cdd9386e
commit d2fd200469
20 changed files with 1171 additions and 1 deletions

2
CODEOWNERS generated
View File

@@ -704,6 +704,8 @@ build.json @home-assistant/supervisor
/tests/components/huawei_lte/ @scop @fphammerle /tests/components/huawei_lte/ @scop @fphammerle
/homeassistant/components/hue/ @marcelveldt /homeassistant/components/hue/ @marcelveldt
/tests/components/hue/ @marcelveldt /tests/components/hue/ @marcelveldt
/homeassistant/components/hue_ble/ @flip-dots
/tests/components/hue_ble/ @flip-dots
/homeassistant/components/huisbaasje/ @dennisschroer /homeassistant/components/huisbaasje/ @dennisschroer
/tests/components/huisbaasje/ @dennisschroer /tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka

View File

@@ -1,5 +1,5 @@
{ {
"domain": "philips", "domain": "philips",
"name": "Philips", "name": "Philips",
"integrations": ["dynalite", "hue", "philips_js"] "integrations": ["dynalite", "hue", "hue_ble", "philips_js"]
} }

View File

@@ -0,0 +1,54 @@
"""Hue BLE integration."""
import logging
from HueBLE import HueBleLight
from homeassistant.components.bluetooth import (
async_ble_device_from_address,
async_scanner_count,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
_LOGGER = logging.getLogger(__name__)
type HueBLEConfigEntry = ConfigEntry[HueBleLight]
async def async_setup_entry(hass: HomeAssistant, entry: HueBLEConfigEntry) -> bool:
"""Set up the integration from a config entry."""
assert entry.unique_id is not None
address = entry.unique_id.upper()
ble_device = async_ble_device_from_address(hass, address, connectable=True)
if ble_device is None:
count_scanners = async_scanner_count(hass, connectable=True)
_LOGGER.debug("Count of BLE scanners: %i", count_scanners)
if count_scanners < 1:
raise ConfigEntryNotReady(
"No Bluetooth scanners are available to search for the light."
)
raise ConfigEntryNotReady("The light was not found.")
light = HueBleLight(ble_device)
if not await light.connect() or not await light.poll_state():
raise ConfigEntryNotReady("Device found but unable to connect.")
entry.runtime_data = light
await hass.config_entries.async_forward_entry_setups(entry, [Platform.LIGHT])
return True
async def async_unload_entry(hass: HomeAssistant, entry: HueBLEConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, [Platform.LIGHT])

View File

@@ -0,0 +1,155 @@
"""Config flow for Hue BLE integration."""
from __future__ import annotations
from enum import Enum
import logging
from typing import Any
from HueBLE import HueBleLight
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.api import (
async_ble_device_from_address,
async_scanner_count,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_MAC, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, URL_PAIRING_MODE
from .light import get_available_color_modes
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
"""Return error if cannot connect and validate."""
ble_device = async_ble_device_from_address(hass, address.upper(), connectable=True)
if ble_device is None:
count_scanners = async_scanner_count(hass, connectable=True)
_LOGGER.debug("Count of BLE scanners in HA bt: %i", count_scanners)
if count_scanners < 1:
return Error.NO_SCANNERS
return Error.NOT_FOUND
try:
light = HueBleLight(ble_device)
await light.connect()
if light.authenticated is None:
_LOGGER.warning(
"Unable to determine if light authenticated, proceeding anyway"
)
elif not light.authenticated:
return Error.INVALID_AUTH
if not light.connected:
return Error.CANNOT_CONNECT
try:
get_available_color_modes(light)
except HomeAssistantError:
return Error.NOT_SUPPORTED
_, errors = await light.poll_state()
if len(errors) != 0:
_LOGGER.warning("Errors raised when connecting to light: %s", errors)
return Error.CANNOT_CONNECT
except Exception:
_LOGGER.exception("Unexpected error validating light connection")
return Error.UNKNOWN
else:
return None
finally:
await light.disconnect()
class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Hue BLE."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
async def async_step_bluetooth(
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a flow initialized by the home assistant scanner."""
_LOGGER.debug(
"HA found light %s. Will show in UI but not auto connect",
discovery_info.name,
)
unique_id = dr.format_mac(discovery_info.address)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
name = f"{discovery_info.name} ({discovery_info.address})"
self.context.update({"title_placeholders": {CONF_NAME: name}})
self._discovery_info = discovery_info
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm a single device."""
assert self._discovery_info is not None
errors: dict[str, str] = {}
if user_input is not None:
unique_id = dr.format_mac(self._discovery_info.address)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
error = await validate_input(self.hass, unique_id)
if error:
errors["base"] = error.value
else:
return self.async_create_entry(title=self._discovery_info.name, data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
errors=errors,
description_placeholders={
CONF_NAME: self._discovery_info.name,
CONF_MAC: self._discovery_info.address,
"url_pairing_mode": URL_PAIRING_MODE,
},
)
class Error(Enum):
"""Potential validation errors when attempting to connect."""
CANNOT_CONNECT = "cannot_connect"
"""Error to indicate we cannot connect."""
INVALID_AUTH = "invalid_auth"
"""Error to indicate there is invalid auth."""
NO_SCANNERS = "no_scanners"
"""Error to indicate no bluetooth scanners are available."""
NOT_FOUND = "not_found"
"""Error to indicate the light could not be found."""
NOT_SUPPORTED = "not_supported"
"""Error to indicate that the light is not a supported model."""
UNKNOWN = "unknown"
"""Error to indicate that the issue is unknown."""

View File

@@ -0,0 +1,4 @@
"""Constants for the Hue BLE integration."""
DOMAIN = "hue_ble"
URL_PAIRING_MODE = "https://www.home-assistant.io/integrations/hue_ble#initial-setup"

View File

@@ -0,0 +1,160 @@
"""Hue BLE light platform."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from HueBLE import HueBleLight
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_XY_COLOR,
ColorMode,
LightEntity,
filter_supported_color_modes,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from . import HueBLEConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HueBLEConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add light for passed config_entry in HA."""
light = config_entry.runtime_data
async_add_entities([HueBLELight(light)])
def get_available_color_modes(api: HueBleLight) -> set[ColorMode]:
"""Return a set of available color modes."""
color_modes = set()
if api.supports_colour_xy:
color_modes.add(ColorMode.XY)
if api.supports_colour_temp:
color_modes.add(ColorMode.COLOR_TEMP)
if api.supports_brightness:
color_modes.add(ColorMode.BRIGHTNESS)
if api.supports_on_off:
color_modes.add(ColorMode.ONOFF)
return filter_supported_color_modes(color_modes)
class HueBLELight(LightEntity):
"""Representation of a light."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, light: HueBleLight) -> None:
"""Initialize the light object. Does not connect."""
self._api = light
self._attr_unique_id = light.address
self._attr_min_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(light.maximum_mireds)
if light.maximum_mireds
else None
)
self._attr_max_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(light.minimum_mireds)
if light.minimum_mireds
else None
)
self._attr_device_info = DeviceInfo(
name=light.name,
connections={(CONNECTION_BLUETOOTH, light.address)},
manufacturer=light.manufacturer,
model_id=light.model,
sw_version=light.firmware,
)
self._attr_supported_color_modes = get_available_color_modes(self._api)
self._update_updatable_attributes()
async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
self._api.add_callback_on_state_changed(self._state_change_callback)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from HA."""
self._api.remove_callback(self._state_change_callback)
def _update_updatable_attributes(self) -> None:
"""Update this entities updatable attrs from the lights state."""
self._attr_available = self._api.available
self._attr_is_on = self._api.power_state
self._attr_brightness = self._api.brightness
self._attr_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(self._api.colour_temp)
if self._api.colour_temp is not None and self._api.colour_temp != 0
else None
)
self._attr_xy_color = self._api.colour_xy
def _state_change_callback(self) -> None:
"""Run when light informs of state update. Updates local properties."""
_LOGGER.debug("Received state notification from light %s", self.name)
self._update_updatable_attributes()
self.async_write_ha_state()
async def async_update(self) -> None:
"""Fetch latest state from light and make available via properties."""
await self._api.poll_state(run_callbacks=True)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Set properties then turn the light on."""
_LOGGER.debug("Turning light %s on with args %s", self.name, kwargs)
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS]
_LOGGER.debug("Setting brightness of %s to %s", self.name, brightness)
await self._api.set_brightness(brightness)
if ATTR_COLOR_TEMP_KELVIN in kwargs:
color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
mireds = color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)
_LOGGER.debug("Setting color temp of %s to %s", self.name, mireds)
await self._api.set_colour_temp(mireds)
if ATTR_XY_COLOR in kwargs:
xy_color = kwargs[ATTR_XY_COLOR]
_LOGGER.debug("Setting XY color of %s to %s", self.name, xy_color)
await self._api.set_colour_xy(xy_color[0], xy_color[1])
await self._api.set_power(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn light off then set properties."""
_LOGGER.debug("Turning light %s off with args %s", self.name, kwargs)
await self._api.set_power(False)
@property
def color_mode(self) -> ColorMode:
"""Color mode of the light."""
if self._api.supports_colour_xy and not self._api.colour_temp_mode:
return ColorMode.XY
if self._api.colour_temp_mode:
return ColorMode.COLOR_TEMP
if self._api.supports_brightness:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF

View File

@@ -0,0 +1,19 @@
{
"domain": "hue_ble",
"name": "Philips Hue BLE",
"bluetooth": [
{
"connectable": true,
"service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb"
}
],
"codeowners": ["@flip-dots"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/hue_ble",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["bleak", "HueBLE"],
"quality_scale": "bronze",
"requirements": ["HueBLE==1.0.8"]
}

View File

@@ -0,0 +1,60 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,21 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"not_implemented": "This integration can only be setup via discovery."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "Invalid authentication. Ensure the light is [made discoverable to voice assistants]({url_pairing_mode}).",
"no_scanners": "No Bluetooth scanners are available to search for the light.",
"not_found": "The light was not found.",
"not_supported": "The light is not a supported model.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"confirm": {
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode})."
}
}
}
}

View File

@@ -319,6 +319,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
], ],
"manufacturer_id": 76, "manufacturer_id": 76,
}, },
{
"connectable": True,
"domain": "hue_ble",
"service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb",
},
{ {
"connectable": True, "connectable": True,
"domain": "husqvarna_automower_ble", "domain": "husqvarna_automower_ble",

View File

@@ -290,6 +290,7 @@ FLOWS = {
"html5", "html5",
"huawei_lte", "huawei_lte",
"hue", "hue",
"hue_ble",
"huisbaasje", "huisbaasje",
"hunterdouglas_powerview", "hunterdouglas_powerview",
"husqvarna_automower", "husqvarna_automower",

View File

@@ -4992,6 +4992,12 @@
"iot_class": "local_push", "iot_class": "local_push",
"name": "Philips Hue" "name": "Philips Hue"
}, },
"hue_ble": {
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push",
"name": "Philips Hue BLE"
},
"philips_js": { "philips_js": {
"integration_type": "hub", "integration_type": "hub",
"config_flow": true, "config_flow": true,

3
requirements_all.txt generated
View File

@@ -21,6 +21,9 @@ HAP-python==5.0.0
# homeassistant.components.tasmota # homeassistant.components.tasmota
HATasmota==0.10.1 HATasmota==0.10.1
# homeassistant.components.hue_ble
HueBLE==1.0.8
# homeassistant.components.mastodon # homeassistant.components.mastodon
Mastodon.py==2.1.2 Mastodon.py==2.1.2

View File

@@ -21,6 +21,9 @@ HAP-python==5.0.0
# homeassistant.components.tasmota # homeassistant.components.tasmota
HATasmota==0.10.1 HATasmota==0.10.1
# homeassistant.components.hue_ble
HueBLE==1.0.8
# homeassistant.components.mastodon # homeassistant.components.mastodon
Mastodon.py==2.1.2 Mastodon.py==2.1.2

View File

@@ -0,0 +1,44 @@
"""Tests for the HueBLE Bluetooth integration."""
from habluetooth import BluetoothServiceInfoBleak
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
TEST_DEVICE_NAME = "Hue Light"
TEST_DEVICE_MAC = "AA:BB:CC:DD:EE:FF"
HUE_BLE_SERVICE_INFO = BluetoothServiceInfoBleak(
name=TEST_DEVICE_NAME,
manufacturer_data={89: b"\x12\x02\x00\x02"},
service_data={"0000fe0f-0000-1000-8000-00805f9b34fb": b"\x02\x10\x0e\xbe\x00"},
service_uuids=[
"00001800-0000-1000-8000-00805f9b34fb",
"00001801-0000-1000-8000-00805f9b34fb",
"0000180a-0000-1000-8000-00805f9b34fb",
"0000fe0f-0000-1000-8000-00805f9b34fb",
"932c32bd-0000-47a2-835a-a8d455b859dd",
"9da2ddf1-0000-44d0-909c-3f3d3cb34a7b",
"b8843add-0000-4aa1-8794-c3f462030bda",
],
address=TEST_DEVICE_MAC,
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name=TEST_DEVICE_NAME,
manufacturer_data={89: b"\xfd`0U\x92W"},
service_data={"0000fe0f-0000-1000-8000-00805f9b34fb": b"\x02\x10\x0e\xbe\x00"},
service_uuids=[
"00001800-0000-1000-8000-00805f9b34fb",
"00001801-0000-1000-8000-00805f9b34fb",
"0000180a-0000-1000-8000-00805f9b34fb",
"0000fe0f-0000-1000-8000-00805f9b34fb",
"932c32bd-0000-47a2-835a-a8d455b859dd",
"9da2ddf1-0000-44d0-909c-3f3d3cb34a7b",
"b8843add-0000-4aa1-8794-c3f462030bda",
],
),
device=generate_ble_device(TEST_DEVICE_MAC, TEST_DEVICE_NAME),
time=0,
connectable=True,
tx_power=-127,
)

View File

@@ -0,0 +1,83 @@
"""Common fixtures for the Hue BLE tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.hue_ble.const import DOMAIN
from . import TEST_DEVICE_MAC, TEST_DEVICE_NAME
from tests.common import MockConfigEntry
from tests.components.bluetooth import generate_ble_device
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.hue_ble.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_scanner_count() -> Generator[AsyncMock]:
"""Override async_scanner_count."""
with patch(
"homeassistant.components.hue_ble.async_scanner_count", return_value=1
) as mock:
yield mock
@pytest.fixture(autouse=True)
def mock_ble_device() -> Generator[AsyncMock]:
"""Override async_scanner_count."""
with patch(
"homeassistant.components.hue_ble.async_ble_device_from_address",
return_value=generate_ble_device(TEST_DEVICE_NAME, TEST_DEVICE_MAC),
) as mock:
yield mock
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth: None):
"""Auto mock bluetooth."""
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Create a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
title=TEST_DEVICE_NAME,
unique_id=TEST_DEVICE_MAC.lower(),
data={},
)
@pytest.fixture
def mock_light() -> Generator[AsyncMock]:
"""Mock a Hue BLE light."""
with patch(
"homeassistant.components.hue_ble.HueBleLight", autospec=True
) as mock_client:
client = mock_client.return_value
client.address = TEST_DEVICE_MAC
client.maximum_mireds = 454
client.minimum_mireds = 153
client.name = TEST_DEVICE_NAME
client.manufacturer = "Signify Netherlands B.V."
client.model = "LTC004"
client.firmware = "1.104.2"
client.supports_colour_xy = True
client.supports_colour_temp = True
client.supports_brightness = True
client.supports_on_off = True
client.available = True
client.power_state = True
client.brightness = 100
client.colour_temp = 250
client.colour_xy = (0.5, 0.5)
yield client

View File

@@ -0,0 +1,84 @@
# serializer version: 1
# name: test_light[light.hue_light-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max_color_temp_kelvin': 6535,
'max_mireds': 454,
'min_color_temp_kelvin': 2202,
'min_mireds': 153,
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.XY: 'xy'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': None,
'entity_id': 'light.hue_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'hue_ble',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'AA:BB:CC:DD:EE:FF',
'unit_of_measurement': None,
})
# ---
# name: test_light[light.hue_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': 100,
'color_mode': <ColorMode.COLOR_TEMP: 'color_temp'>,
'color_temp': 250,
'color_temp_kelvin': 4000,
'friendly_name': 'Hue Light',
'hs_color': tuple(
26.812,
34.87,
),
'max_color_temp_kelvin': 6535,
'max_mireds': 454,
'min_color_temp_kelvin': 2202,
'min_mireds': 153,
'rgb_color': tuple(
255,
206,
166,
),
'supported_color_modes': list([
<ColorMode.COLOR_TEMP: 'color_temp'>,
<ColorMode.XY: 'xy'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': tuple(
0.42,
0.365,
),
}),
'context': <ANY>,
'entity_id': 'light.hue_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -0,0 +1,314 @@
"""Test the Hue BLE config flow."""
from unittest.mock import AsyncMock, PropertyMock, patch
import pytest
from homeassistant import config_entries
from homeassistant.components.hue_ble.config_flow import Error
from homeassistant.components.hue_ble.const import DOMAIN, URL_PAIRING_MODE
from homeassistant.config_entries import SOURCE_BLUETOOTH
from homeassistant.const import CONF_MAC, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr
from . import HUE_BLE_SERVICE_INFO, TEST_DEVICE_MAC, TEST_DEVICE_NAME
from tests.common import MockConfigEntry
from tests.components.bluetooth import BLEDevice, generate_ble_device
@pytest.mark.parametrize(
("mock_authenticated"),
[
(True,),
(None),
],
ids=[
"normal",
"unknown_auth",
],
)
async def test_bluetooth_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_authenticated: bool | None,
) -> None:
"""Test bluetooth discovery form."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=HUE_BLE_SERVICE_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
assert result["description_placeholders"] == {
CONF_NAME: TEST_DEVICE_NAME,
CONF_MAC: TEST_DEVICE_MAC,
"url_pairing_mode": URL_PAIRING_MODE,
}
with (
patch(
"homeassistant.components.hue_ble.config_flow.async_ble_device_from_address",
return_value=generate_ble_device(TEST_DEVICE_NAME, TEST_DEVICE_MAC),
),
patch(
"homeassistant.components.hue_ble.config_flow.async_scanner_count",
return_value=1,
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.connect",
side_effect=[True],
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.poll_state",
side_effect=[(True, [])],
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.connected",
new_callable=PropertyMock,
return_value=True,
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.authenticated",
new_callable=PropertyMock,
return_value=mock_authenticated,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_DEVICE_NAME
assert result["result"].unique_id == dr.format_mac(TEST_DEVICE_MAC)
assert result["result"].data == {}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
(
"mock_return_device",
"mock_scanner_count",
"mock_connect",
"mock_authenticated",
"mock_connected",
"mock_support_on_off",
"mock_poll_state",
"error",
),
[
(
None,
0,
True,
True,
True,
True,
(True, []),
Error.NO_SCANNERS,
),
(
None,
1,
True,
True,
True,
True,
(True, []),
Error.NOT_FOUND,
),
(
generate_ble_device(TEST_DEVICE_NAME, TEST_DEVICE_MAC),
1,
True,
False,
True,
True,
(True, []),
Error.INVALID_AUTH,
),
(
generate_ble_device(TEST_DEVICE_NAME, TEST_DEVICE_MAC),
1,
True,
True,
False,
True,
(True, []),
Error.CANNOT_CONNECT,
),
(
generate_ble_device(TEST_DEVICE_NAME, TEST_DEVICE_MAC),
1,
True,
True,
True,
False,
(True, []),
Error.NOT_SUPPORTED,
),
(
generate_ble_device(TEST_DEVICE_NAME, TEST_DEVICE_MAC),
1,
True,
True,
True,
True,
(True, ["ERROR!"]),
Error.CANNOT_CONNECT,
),
(
generate_ble_device(TEST_DEVICE_NAME, TEST_DEVICE_MAC),
1,
Exception,
None,
True,
True,
(True, []),
Error.UNKNOWN,
),
],
ids=[
"no_scanners",
"not_found",
"invalid_auth",
"cannot_connect",
"not_supported",
"cannot_poll",
"unknown",
],
)
async def test_bluetooth_form_exception(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_return_device: BLEDevice | None,
mock_scanner_count: int,
mock_connect: Exception | bool,
mock_authenticated: bool | None,
mock_connected: bool,
mock_support_on_off: bool,
mock_poll_state: Exception | tuple[bool, list[Exception]],
error: Error,
) -> None:
"""Test bluetooth discovery form with errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=HUE_BLE_SERVICE_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
with (
patch(
"homeassistant.components.hue_ble.config_flow.async_ble_device_from_address",
return_value=mock_return_device,
),
patch(
"homeassistant.components.hue_ble.config_flow.async_scanner_count",
return_value=mock_scanner_count,
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.connect",
side_effect=[mock_connect],
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.supports_on_off",
new_callable=PropertyMock,
return_value=mock_support_on_off,
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.poll_state",
side_effect=[mock_poll_state],
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.connected",
new_callable=PropertyMock,
return_value=mock_connected,
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.authenticated",
new_callable=PropertyMock,
return_value=mock_authenticated,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error.value}
with (
patch(
"homeassistant.components.hue_ble.config_flow.async_ble_device_from_address",
return_value=generate_ble_device(TEST_DEVICE_NAME, TEST_DEVICE_MAC),
),
patch(
"homeassistant.components.hue_ble.config_flow.async_scanner_count",
return_value=1,
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.connect",
side_effect=[True],
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.poll_state",
side_effect=[(True, [])],
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.connected",
new_callable=PropertyMock,
return_value=True,
),
patch(
"homeassistant.components.hue_ble.config_flow.HueBleLight.authenticated",
new_callable=PropertyMock,
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_user_form_exception(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the user form raises a discovery only error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "not_implemented"
async def test_bluetooth_form_exception_already_set_up(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test bluetooth discovery form when device is already set up."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=HUE_BLE_SERVICE_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,123 @@
"""Test hue_ble setup process."""
from unittest.mock import patch
from bleak.backends.device import BLEDevice
import pytest
from homeassistant.components.hue_ble.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from . import TEST_DEVICE_MAC, TEST_DEVICE_NAME
from tests.common import MockConfigEntry
from tests.components.bluetooth import generate_ble_device
@pytest.mark.parametrize(
(
"ble_device",
"scanner_count",
"connect_result",
"poll_state_result",
"message",
),
[
(
None,
2,
True,
True,
"The light was not found.",
),
(
None,
0,
True,
True,
"No Bluetooth scanners are available to search for the light.",
),
(
generate_ble_device(TEST_DEVICE_MAC, TEST_DEVICE_NAME),
2,
False,
True,
"Device found but unable to connect.",
),
(
generate_ble_device(TEST_DEVICE_MAC, TEST_DEVICE_NAME),
2,
True,
False,
"Device found but unable to connect.",
),
],
ids=["no_device", "no_scanners", "error_connect", "error_poll"],
)
async def test_setup_error(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
ble_device: BLEDevice | None,
scanner_count: int,
connect_result: bool,
poll_state_result: bool,
message: str,
) -> None:
"""Test that ConfigEntryNotReady is raised if there is an error condition."""
entry = MockConfigEntry(domain=DOMAIN, unique_id="abcd", data={})
entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.hue_ble.async_ble_device_from_address",
return_value=ble_device,
),
patch(
"homeassistant.components.hue_ble.async_scanner_count",
return_value=scanner_count,
),
patch(
"homeassistant.components.hue_ble.HueBleLight.connect",
return_value=connect_result,
),
patch(
"homeassistant.components.hue_ble.HueBleLight.poll_state",
return_value=poll_state_result,
),
):
assert await async_setup_component(hass, DOMAIN, {}) is True
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert message in caplog.text
async def test_setup(
hass: HomeAssistant,
) -> None:
"""Test that the config is loaded if there are no errors."""
entry = MockConfigEntry(domain=DOMAIN, unique_id="abcd", data={})
entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.hue_ble.async_ble_device_from_address",
return_value=generate_ble_device(TEST_DEVICE_MAC, TEST_DEVICE_NAME),
),
patch(
"homeassistant.components.hue_ble.async_scanner_count",
return_value=1,
),
patch(
"homeassistant.components.hue_ble.HueBleLight.connect",
return_value=True,
),
patch(
"homeassistant.components.hue_ble.HueBleLight.poll_state",
return_value=True,
),
):
assert await async_setup_component(hass, DOMAIN, {}) is True
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED

View File

@@ -0,0 +1,29 @@
"""Hue BLE light tests."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
async def test_light(
hass: HomeAssistant,
mock_scanner_count: AsyncMock,
mock_light: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test light entity setup."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)