diff --git a/CODEOWNERS b/CODEOWNERS index f1645f95759..f27040447d7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -704,6 +704,8 @@ build.json @home-assistant/supervisor /tests/components/huawei_lte/ @scop @fphammerle /homeassistant/components/hue/ @marcelveldt /tests/components/hue/ @marcelveldt +/homeassistant/components/hue_ble/ @flip-dots +/tests/components/hue_ble/ @flip-dots /homeassistant/components/huisbaasje/ @dennisschroer /tests/components/huisbaasje/ @dennisschroer /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka diff --git a/homeassistant/brands/philips.json b/homeassistant/brands/philips.json index bfd290eb945..4d2c1584db4 100644 --- a/homeassistant/brands/philips.json +++ b/homeassistant/brands/philips.json @@ -1,5 +1,5 @@ { "domain": "philips", "name": "Philips", - "integrations": ["dynalite", "hue", "philips_js"] + "integrations": ["dynalite", "hue", "hue_ble", "philips_js"] } diff --git a/homeassistant/components/hue_ble/__init__.py b/homeassistant/components/hue_ble/__init__.py new file mode 100644 index 00000000000..06a4a738b46 --- /dev/null +++ b/homeassistant/components/hue_ble/__init__.py @@ -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]) diff --git a/homeassistant/components/hue_ble/config_flow.py b/homeassistant/components/hue_ble/config_flow.py new file mode 100644 index 00000000000..e7b4409c789 --- /dev/null +++ b/homeassistant/components/hue_ble/config_flow.py @@ -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.""" diff --git a/homeassistant/components/hue_ble/const.py b/homeassistant/components/hue_ble/const.py new file mode 100644 index 00000000000..741c8e31070 --- /dev/null +++ b/homeassistant/components/hue_ble/const.py @@ -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" diff --git a/homeassistant/components/hue_ble/light.py b/homeassistant/components/hue_ble/light.py new file mode 100644 index 00000000000..434c5cb9092 --- /dev/null +++ b/homeassistant/components/hue_ble/light.py @@ -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 diff --git a/homeassistant/components/hue_ble/manifest.json b/homeassistant/components/hue_ble/manifest.json new file mode 100644 index 00000000000..feb0c45cbbc --- /dev/null +++ b/homeassistant/components/hue_ble/manifest.json @@ -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"] +} diff --git a/homeassistant/components/hue_ble/quality_scale.yaml b/homeassistant/components/hue_ble/quality_scale.yaml new file mode 100644 index 00000000000..65f3d83860b --- /dev/null +++ b/homeassistant/components/hue_ble/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/hue_ble/strings.json b/homeassistant/components/hue_ble/strings.json new file mode 100644 index 00000000000..9c80bd20487 --- /dev/null +++ b/homeassistant/components/hue_ble/strings.json @@ -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})." + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 305df305d41..f212b6aadb4 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -319,6 +319,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ ], "manufacturer_id": 76, }, + { + "connectable": True, + "domain": "hue_ble", + "service_data_uuid": "0000fe0f-0000-1000-8000-00805f9b34fb", + }, { "connectable": True, "domain": "husqvarna_automower_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7ccfcb4b1cd..a4d69a38e11 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -290,6 +290,7 @@ FLOWS = { "html5", "huawei_lte", "hue", + "hue_ble", "huisbaasje", "hunterdouglas_powerview", "husqvarna_automower", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d59c3f40751..f1fc77cb776 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4992,6 +4992,12 @@ "iot_class": "local_push", "name": "Philips Hue" }, + "hue_ble": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push", + "name": "Philips Hue BLE" + }, "philips_js": { "integration_type": "hub", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 95d6bbdc8b8..f64b4285d8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -21,6 +21,9 @@ HAP-python==5.0.0 # homeassistant.components.tasmota HATasmota==0.10.1 +# homeassistant.components.hue_ble +HueBLE==1.0.8 + # homeassistant.components.mastodon Mastodon.py==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e9d913f410..638041bd9b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,6 +21,9 @@ HAP-python==5.0.0 # homeassistant.components.tasmota HATasmota==0.10.1 +# homeassistant.components.hue_ble +HueBLE==1.0.8 + # homeassistant.components.mastodon Mastodon.py==2.1.2 diff --git a/tests/components/hue_ble/__init__.py b/tests/components/hue_ble/__init__.py new file mode 100644 index 00000000000..a80a28df538 --- /dev/null +++ b/tests/components/hue_ble/__init__.py @@ -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, +) diff --git a/tests/components/hue_ble/conftest.py b/tests/components/hue_ble/conftest.py new file mode 100644 index 00000000000..44823f940b0 --- /dev/null +++ b/tests/components/hue_ble/conftest.py @@ -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 diff --git a/tests/components/hue_ble/snapshots/test_light.ambr b/tests/components/hue_ble/snapshots/test_light.ambr new file mode 100644 index 00000000000..6a61270743e --- /dev/null +++ b/tests/components/hue_ble/snapshots/test_light.ambr @@ -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([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + '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([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.42, + 0.365, + ), + }), + 'context': , + 'entity_id': 'light.hue_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/hue_ble/test_config_flow.py b/tests/components/hue_ble/test_config_flow.py new file mode 100644 index 00000000000..ea08a3fa656 --- /dev/null +++ b/tests/components/hue_ble/test_config_flow.py @@ -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" diff --git a/tests/components/hue_ble/test_init.py b/tests/components/hue_ble/test_init.py new file mode 100644 index 00000000000..aa70ab68652 --- /dev/null +++ b/tests/components/hue_ble/test_init.py @@ -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 diff --git a/tests/components/hue_ble/test_light.py b/tests/components/hue_ble/test_light.py new file mode 100644 index 00000000000..8b433dc1bce --- /dev/null +++ b/tests/components/hue_ble/test_light.py @@ -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)