Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New integration: Hue BLE #118635

Open
wants to merge 47 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
91ac8da
Initial implementation of hue_ble.
flip-dots May 12, 2024
1e64581
Update deps
flip-dots May 12, 2024
ab74435
Update deps
flip-dots May 12, 2024
a256334
Resolve issue with unsupported light mode warning.
flip-dots May 18, 2024
31796d8
Update deps
flip-dots May 18, 2024
3a9d732
Add tests for hue_ble
flip-dots May 18, 2024
c367f34
Update codeowners
flip-dots May 18, 2024
04acf3a
Update strings to be more specific.
flip-dots May 18, 2024
87cfbbb
Update deps
flip-dots Jun 2, 2024
675ca26
Add error messages to bluetooth discovery flow
flip-dots Jun 17, 2024
fabfa70
Increase test coverage of config flow
flip-dots Jun 17, 2024
2a65ec2
Replace usage of redundant form data in config entry
flip-dots Jun 30, 2024
29489e5
Replace hass.data with entry.runtime_data
flip-dots Jun 30, 2024
315c76e
Remove redundant device info
flip-dots Jun 30, 2024
fb259a8
Remove redundant attr
flip-dots Jun 30, 2024
238b394
Add light entity description
flip-dots Jun 30, 2024
22facbe
Remove (mostly) static state variables from state updates
flip-dots Jun 30, 2024
b6c1b46
Replace usage of attr state update with properties
flip-dots Jun 30, 2024
f5ec557
Update intergration name
flip-dots Jun 30, 2024
3a06ad2
Update deps
flip-dots Jun 30, 2024
55331c6
Add support for light authentication status being unknown
flip-dots Jun 30, 2024
a8aae16
Simplify light color_mode detection
flip-dots Jul 2, 2024
1df62c3
Refactor tests
flip-dots Jul 2, 2024
62f82d9
Refactor multiple mock patches into one
flip-dots Jul 13, 2024
4922e67
Replace "==" with "is"
flip-dots Jul 13, 2024
f23ddfc
Add URL to docs
flip-dots Jul 13, 2024
801f389
Add link to docs in setup form
flip-dots Jul 13, 2024
26f23d0
Simplify test parametrization
flip-dots Jul 14, 2024
9193b08
Simplify test parametrization
flip-dots Jul 14, 2024
f5076ad
Add type
flip-dots Sep 8, 2024
7c3d21c
Add type
flip-dots Sep 8, 2024
fa5138e
Fix imports & run lint tool
flip-dots Sep 15, 2024
9b434e5
Run ruff format
flip-dots Sep 15, 2024
7a794eb
Regenerate requirements
flip-dots Sep 15, 2024
0d0d8a5
Fix lint issue in tests
flip-dots Sep 15, 2024
14de252
Use specific type
flip-dots Oct 20, 2024
d8046ec
Remove support for manual configuration
flip-dots Oct 20, 2024
8ac4771
Add hue_ble as brand of phillips
flip-dots Oct 20, 2024
a9c7c1e
Run pylint
flip-dots Oct 20, 2024
b57f276
Replace state properties with state attributes
flip-dots Dec 21, 2024
b425bc0
Remove entity description
flip-dots Dec 21, 2024
f750aa7
Remove should_poll
flip-dots Dec 21, 2024
9258012
Replace branch with context manager
flip-dots Dec 21, 2024
c4cec83
Add hyperlink to docs for auth error message
flip-dots Dec 21, 2024
cebe324
Fix error when running script.hassfest
flip-dots Jan 11, 2025
386d11c
Fix issue with light name not being shown
flip-dots Jan 11, 2025
88756d4
Use quality scale
flip-dots Jan 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion homeassistant/brands/philips.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"domain": "philips",
"name": "Philips",
"integrations": ["dynalite", "hue", "philips_js"]
"integrations": ["dynalite", "hue", "hue_ble", "philips_js"]
}
54 changes: 54 additions & 0 deletions homeassistant/components/hue_ble/__init__.py
Original file line number Diff line number Diff line change
@@ -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.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.")
Comment on lines +29 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to know the scanner count?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw this in one of the integrations I based this on and I kept it because I'd rather the error for not being able to find it and not being able to find it because there is nothing available to search for it be different things.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is somewhat valid, but I also think this kind of check should live in some BLE integration helper so not every integration has to duplicate such code.
@bdraco, do we have some helper like this already?


light = HueBleLight(ble_device)

if not await light.connect() or not await light.poll_state():
raise ConfigEntryNotReady("Device found but unable to connect.")
Comment on lines +38 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, we do a lot of checks here to ensure the light can be found when setting up the integration.
What happens if the light is found now, but connection is then lost, does the HueBLE library automatically reconnect?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It automatically attempts to reconnect once but if that does not work it marks itself as unavailable, but any command sent to it afterwards will initiate a reconnection attempt.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain this in some more detail please? Who needs to send a command to trigger a reconnection attempt?

Copy link
Contributor Author

@flip-dots flip-dots Oct 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like I had gotten myself confused with how it worked in an earlier iteration.

The way the library currently works is that if it successfully connects and then becomes disconnected without the disconnect method being called it will attempt to reconnect indefinitely. No command is needed to trigger a re-connection attempt, it does it by itself.

If the maximum re-connect attempts variable was not set to -1 then it would stop after however many attempts, in that case if any command was sent requiring communication (e.g poll_status, set_power, etc) it would then try to re-connect 3 more times by default before giving up. So basically it will auto-reconnect by itself but in addition to that any command which requires a connection to work will also cause a re-connection attempt if it was disconnected.


entry.runtime_data = light

hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "light")
)
return True


async def async_unload_entry(hass: HomeAssistant, entry: HueBLEConfigEntry) -> bool:
"""Unload a config entry."""

return await hass.config_entries.async_forward_entry_unload(entry, "light")
146 changes: 146 additions & 0 deletions homeassistant/components/hue_ble/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Config flow for Hue BLE integration."""
flip-dots marked this conversation as resolved.
Show resolved Hide resolved

from __future__ import annotations

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

_LOGGER = logging.getLogger(__name__)


async def validate_input(hass: HomeAssistant, address: str) -> None:
"""Validate that we can connect."""

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:
raise ScannerNotAvailable
raise NotFound

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:
raise InvalidAuth

if not light.connected:
raise CannotConnect

state_changed, errors = await light.poll_state()
if not len(errors) == 0:
raise CannotConnect
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:
try:
unique_id = dr.format_mac(self._discovery_info.address)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
await validate_input(self.hass, unique_id)

except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except ScannerNotAvailable:
errors["base"] = "no_scanners"
except NotFound:
errors["base"] = "not_found"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
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 CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""


class ScannerNotAvailable(HomeAssistantError):
"""Error to indicate no bluetooth scanners are available."""


class NotFound(HomeAssistantError):
"""Error to indicate the light could not be found."""
4 changes: 4 additions & 0 deletions homeassistant/components/hue_ble/const.py
Original file line number Diff line number Diff line change
@@ -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"
168 changes: 168 additions & 0 deletions homeassistant/components/hue_ble/light.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""light platform."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

from HueBLE import HueBleLight

from homeassistant.components.light import (

Check failure on line 10 in homeassistant/components/hue_ble/light.py

View workflow job for this annotation

GitHub Actions / Check pylint

E0611: No name 'ATTR_COLOR_TEMP' in module 'homeassistant.components.light' (no-name-in-module)
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP,
ATTR_XY_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback

_LOGGER = logging.getLogger(__name__)


if TYPE_CHECKING:
from . import HueBLEConfigEntry


async def async_setup_entry(
hass: HomeAssistant,
config_entry: HueBLEConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add light for passed config_entry in HA."""

light = config_entry.runtime_data
async_add_entities([HaHueBLE(light)])


class HaHueBLE(LightEntity):
"""Representation of a light."""

flip-dots marked this conversation as resolved.
Show resolved Hide resolved
_attr_has_entity_name = True
_attr_name = None

def __init__(self, api: HueBleLight) -> None:
"""Initialize the light object. Does not connect."""

self._light = api
self._name = self._light.name
self._address = self._light.address
self._attr_unique_id = self._light.address
self._attr_available = self._light.available
self._attr_is_on = self._light.power_state
self._attr_brightness = self._light.brightness
self._attr_color_temp = self._light.colour_temp

Check failure on line 55 in homeassistant/components/hue_ble/light.py

View workflow job for this annotation

GitHub Actions / Check mypy

Cannot assign to final attribute "_attr_color_temp" [misc]
self._attr_xy_color = self._light.colour_xy
self._attr_min_mireds = self._light.minimum_mireds

Check failure on line 57 in homeassistant/components/hue_ble/light.py

View workflow job for this annotation

GitHub Actions / Check mypy

Cannot assign to final attribute "_attr_min_mireds" [misc]
self._attr_max_mireds = self._light.maximum_mireds

Check failure on line 58 in homeassistant/components/hue_ble/light.py

View workflow job for this annotation

GitHub Actions / Check mypy

Cannot assign to final attribute "_attr_max_mireds" [misc]
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, self._light.address)},
manufacturer=self._light.manufacturer,
model=self._light.model,
sw_version=self._light.firmware,
)

async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""

self._light.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._light.remove_callback(self._state_change_callback)

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.async_write_ha_state()

async def async_update(self) -> None:
"""Fetch latest state from light and make available via properties."""
await self._light.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._light.set_brightness(brightness)

if ATTR_COLOR_TEMP in kwargs:
mireds = kwargs[ATTR_COLOR_TEMP]
_LOGGER.debug("Setting color temp of %s to %s", self.name, mireds)
await self._light.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._light.set_colour_xy(xy_color[0], xy_color[1])

await self._light.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._light.set_power(False)

if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS]
_LOGGER.debug("Setting brightness of %s to %s", self.name, brightness)
await self._light.set_brightness(brightness)

if ATTR_COLOR_TEMP in kwargs:
mireds = kwargs[ATTR_COLOR_TEMP]
_LOGGER.debug("Setting color temp of %s to %s", self.name, mireds)
await self._light.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._light.set_colour_xy(xy_color[0], xy_color[1])

@property
def supported_color_modes(self) -> set[ColorMode] | None:
"""Flag supported color modes."""

supported_modes = set()

if self._light.supports_colour_xy:
supported_modes.add(ColorMode.XY)

if self._light.supports_colour_temp:
supported_modes.add(ColorMode.COLOR_TEMP)

if self._light.supports_brightness and len(supported_modes) == 0:
supported_modes.add(ColorMode.BRIGHTNESS)

if self._light.supports_on_off and len(supported_modes) == 0:
supported_modes.add(ColorMode.ONOFF)

if len(supported_modes) == 0:
supported_modes.add(ColorMode.UNKNOWN)
Comment on lines +145 to +149
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there lights which can't be turned on or off?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would hope not, but I designed the library to be as extendible as possible. Its possible that other models use different Bluetooth attributes to be controlled and therefore might not support turning on and off using the addresses known to the library. Implementing it this way would make it easier to diagnose and hopefully later support other models (assuming that all models aren't already compatible, I only have one model to test).


return supported_modes

@property
def color_mode(self) -> ColorMode | None:
"""Color mode of the light."""

if self._light.supports_colour_xy:
if self._light.supports_colour_temp and self._light.colour_temp_mode:
return ColorMode.COLOR_TEMP
return ColorMode.XY

if self._light.supports_brightness:
return ColorMode.BRIGHTNESS

if self._light.supports_on_off:
return ColorMode.ONOFF

return ColorMode.UNKNOWN
Comment on lines +165 to +168
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question, are there lights which can't be turned on or off?

Loading
Loading