Skip to content

Commit d2fd200

Browse files
flip-dotstr4nt0remontnemeryjoostlek
authored
New integration: Hue BLE (home-assistant#118635)
Co-authored-by: Mr. Bubbles <[email protected]> Co-authored-by: Erik Montnemery <[email protected]> Co-authored-by: Joostlek <[email protected]>
1 parent 20cdd93 commit d2fd200

File tree

20 files changed

+1171
-1
lines changed

20 files changed

+1171
-1
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

homeassistant/brands/philips.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"domain": "philips",
33
"name": "Philips",
4-
"integrations": ["dynalite", "hue", "philips_js"]
4+
"integrations": ["dynalite", "hue", "hue_ble", "philips_js"]
55
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Hue BLE integration."""
2+
3+
import logging
4+
5+
from HueBLE import HueBleLight
6+
7+
from homeassistant.components.bluetooth import (
8+
async_ble_device_from_address,
9+
async_scanner_count,
10+
)
11+
from homeassistant.config_entries import ConfigEntry
12+
from homeassistant.const import Platform
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import ConfigEntryNotReady
15+
16+
_LOGGER = logging.getLogger(__name__)
17+
18+
type HueBLEConfigEntry = ConfigEntry[HueBleLight]
19+
20+
21+
async def async_setup_entry(hass: HomeAssistant, entry: HueBLEConfigEntry) -> bool:
22+
"""Set up the integration from a config entry."""
23+
24+
assert entry.unique_id is not None
25+
address = entry.unique_id.upper()
26+
27+
ble_device = async_ble_device_from_address(hass, address, connectable=True)
28+
29+
if ble_device is None:
30+
count_scanners = async_scanner_count(hass, connectable=True)
31+
_LOGGER.debug("Count of BLE scanners: %i", count_scanners)
32+
33+
if count_scanners < 1:
34+
raise ConfigEntryNotReady(
35+
"No Bluetooth scanners are available to search for the light."
36+
)
37+
raise ConfigEntryNotReady("The light was not found.")
38+
39+
light = HueBleLight(ble_device)
40+
41+
if not await light.connect() or not await light.poll_state():
42+
raise ConfigEntryNotReady("Device found but unable to connect.")
43+
44+
entry.runtime_data = light
45+
46+
await hass.config_entries.async_forward_entry_setups(entry, [Platform.LIGHT])
47+
48+
return True
49+
50+
51+
async def async_unload_entry(hass: HomeAssistant, entry: HueBLEConfigEntry) -> bool:
52+
"""Unload a config entry."""
53+
54+
return await hass.config_entries.async_unload_platforms(entry, [Platform.LIGHT])
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Config flow for Hue BLE integration."""
2+
3+
from __future__ import annotations
4+
5+
from enum import Enum
6+
import logging
7+
from typing import Any
8+
9+
from HueBLE import HueBleLight
10+
import voluptuous as vol
11+
12+
from homeassistant.components import bluetooth
13+
from homeassistant.components.bluetooth.api import (
14+
async_ble_device_from_address,
15+
async_scanner_count,
16+
)
17+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
18+
from homeassistant.const import CONF_MAC, CONF_NAME
19+
from homeassistant.core import HomeAssistant
20+
from homeassistant.exceptions import HomeAssistantError
21+
from homeassistant.helpers import device_registry as dr
22+
23+
from .const import DOMAIN, URL_PAIRING_MODE
24+
from .light import get_available_color_modes
25+
26+
_LOGGER = logging.getLogger(__name__)
27+
28+
29+
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
30+
"""Return error if cannot connect and validate."""
31+
32+
ble_device = async_ble_device_from_address(hass, address.upper(), connectable=True)
33+
34+
if ble_device is None:
35+
count_scanners = async_scanner_count(hass, connectable=True)
36+
_LOGGER.debug("Count of BLE scanners in HA bt: %i", count_scanners)
37+
38+
if count_scanners < 1:
39+
return Error.NO_SCANNERS
40+
return Error.NOT_FOUND
41+
42+
try:
43+
light = HueBleLight(ble_device)
44+
45+
await light.connect()
46+
47+
if light.authenticated is None:
48+
_LOGGER.warning(
49+
"Unable to determine if light authenticated, proceeding anyway"
50+
)
51+
elif not light.authenticated:
52+
return Error.INVALID_AUTH
53+
54+
if not light.connected:
55+
return Error.CANNOT_CONNECT
56+
57+
try:
58+
get_available_color_modes(light)
59+
except HomeAssistantError:
60+
return Error.NOT_SUPPORTED
61+
62+
_, errors = await light.poll_state()
63+
if len(errors) != 0:
64+
_LOGGER.warning("Errors raised when connecting to light: %s", errors)
65+
return Error.CANNOT_CONNECT
66+
67+
except Exception:
68+
_LOGGER.exception("Unexpected error validating light connection")
69+
return Error.UNKNOWN
70+
else:
71+
return None
72+
finally:
73+
await light.disconnect()
74+
75+
76+
class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
77+
"""Handle a config flow for Hue BLE."""
78+
79+
VERSION = 1
80+
81+
def __init__(self) -> None:
82+
"""Initialize the config flow."""
83+
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
84+
85+
async def async_step_bluetooth(
86+
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
87+
) -> ConfigFlowResult:
88+
"""Handle a flow initialized by the home assistant scanner."""
89+
90+
_LOGGER.debug(
91+
"HA found light %s. Will show in UI but not auto connect",
92+
discovery_info.name,
93+
)
94+
95+
unique_id = dr.format_mac(discovery_info.address)
96+
await self.async_set_unique_id(unique_id)
97+
self._abort_if_unique_id_configured()
98+
99+
name = f"{discovery_info.name} ({discovery_info.address})"
100+
self.context.update({"title_placeholders": {CONF_NAME: name}})
101+
102+
self._discovery_info = discovery_info
103+
104+
return await self.async_step_confirm()
105+
106+
async def async_step_confirm(
107+
self, user_input: dict[str, Any] | None = None
108+
) -> ConfigFlowResult:
109+
"""Confirm a single device."""
110+
111+
assert self._discovery_info is not None
112+
errors: dict[str, str] = {}
113+
114+
if user_input is not None:
115+
unique_id = dr.format_mac(self._discovery_info.address)
116+
await self.async_set_unique_id(unique_id)
117+
self._abort_if_unique_id_configured()
118+
error = await validate_input(self.hass, unique_id)
119+
if error:
120+
errors["base"] = error.value
121+
else:
122+
return self.async_create_entry(title=self._discovery_info.name, data={})
123+
124+
return self.async_show_form(
125+
step_id="confirm",
126+
data_schema=vol.Schema({}),
127+
errors=errors,
128+
description_placeholders={
129+
CONF_NAME: self._discovery_info.name,
130+
CONF_MAC: self._discovery_info.address,
131+
"url_pairing_mode": URL_PAIRING_MODE,
132+
},
133+
)
134+
135+
136+
class Error(Enum):
137+
"""Potential validation errors when attempting to connect."""
138+
139+
CANNOT_CONNECT = "cannot_connect"
140+
"""Error to indicate we cannot connect."""
141+
142+
INVALID_AUTH = "invalid_auth"
143+
"""Error to indicate there is invalid auth."""
144+
145+
NO_SCANNERS = "no_scanners"
146+
"""Error to indicate no bluetooth scanners are available."""
147+
148+
NOT_FOUND = "not_found"
149+
"""Error to indicate the light could not be found."""
150+
151+
NOT_SUPPORTED = "not_supported"
152+
"""Error to indicate that the light is not a supported model."""
153+
154+
UNKNOWN = "unknown"
155+
"""Error to indicate that the issue is unknown."""
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Constants for the Hue BLE integration."""
2+
3+
DOMAIN = "hue_ble"
4+
URL_PAIRING_MODE = "https://www.home-assistant.io/integrations/hue_ble#initial-setup"
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Hue BLE light platform."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING, Any
7+
8+
from HueBLE import HueBleLight
9+
10+
from homeassistant.components.light import (
11+
ATTR_BRIGHTNESS,
12+
ATTR_COLOR_TEMP_KELVIN,
13+
ATTR_XY_COLOR,
14+
ColorMode,
15+
LightEntity,
16+
filter_supported_color_modes,
17+
)
18+
from homeassistant.core import HomeAssistant
19+
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
20+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
21+
from homeassistant.util import color as color_util
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
26+
if TYPE_CHECKING:
27+
from . import HueBLEConfigEntry
28+
29+
30+
async def async_setup_entry(
31+
hass: HomeAssistant,
32+
config_entry: HueBLEConfigEntry,
33+
async_add_entities: AddConfigEntryEntitiesCallback,
34+
) -> None:
35+
"""Add light for passed config_entry in HA."""
36+
37+
light = config_entry.runtime_data
38+
async_add_entities([HueBLELight(light)])
39+
40+
41+
def get_available_color_modes(api: HueBleLight) -> set[ColorMode]:
42+
"""Return a set of available color modes."""
43+
color_modes = set()
44+
if api.supports_colour_xy:
45+
color_modes.add(ColorMode.XY)
46+
if api.supports_colour_temp:
47+
color_modes.add(ColorMode.COLOR_TEMP)
48+
if api.supports_brightness:
49+
color_modes.add(ColorMode.BRIGHTNESS)
50+
if api.supports_on_off:
51+
color_modes.add(ColorMode.ONOFF)
52+
return filter_supported_color_modes(color_modes)
53+
54+
55+
class HueBLELight(LightEntity):
56+
"""Representation of a light."""
57+
58+
_attr_has_entity_name = True
59+
_attr_name = None
60+
61+
def __init__(self, light: HueBleLight) -> None:
62+
"""Initialize the light object. Does not connect."""
63+
64+
self._api = light
65+
self._attr_unique_id = light.address
66+
self._attr_min_color_temp_kelvin = (
67+
color_util.color_temperature_mired_to_kelvin(light.maximum_mireds)
68+
if light.maximum_mireds
69+
else None
70+
)
71+
self._attr_max_color_temp_kelvin = (
72+
color_util.color_temperature_mired_to_kelvin(light.minimum_mireds)
73+
if light.minimum_mireds
74+
else None
75+
)
76+
self._attr_device_info = DeviceInfo(
77+
name=light.name,
78+
connections={(CONNECTION_BLUETOOTH, light.address)},
79+
manufacturer=light.manufacturer,
80+
model_id=light.model,
81+
sw_version=light.firmware,
82+
)
83+
self._attr_supported_color_modes = get_available_color_modes(self._api)
84+
self._update_updatable_attributes()
85+
86+
async def async_added_to_hass(self) -> None:
87+
"""Run when this Entity has been added to HA."""
88+
89+
self._api.add_callback_on_state_changed(self._state_change_callback)
90+
91+
async def async_will_remove_from_hass(self) -> None:
92+
"""Run when entity will be removed from HA."""
93+
94+
self._api.remove_callback(self._state_change_callback)
95+
96+
def _update_updatable_attributes(self) -> None:
97+
"""Update this entities updatable attrs from the lights state."""
98+
self._attr_available = self._api.available
99+
self._attr_is_on = self._api.power_state
100+
self._attr_brightness = self._api.brightness
101+
self._attr_color_temp_kelvin = (
102+
color_util.color_temperature_mired_to_kelvin(self._api.colour_temp)
103+
if self._api.colour_temp is not None and self._api.colour_temp != 0
104+
else None
105+
)
106+
self._attr_xy_color = self._api.colour_xy
107+
108+
def _state_change_callback(self) -> None:
109+
"""Run when light informs of state update. Updates local properties."""
110+
_LOGGER.debug("Received state notification from light %s", self.name)
111+
self._update_updatable_attributes()
112+
self.async_write_ha_state()
113+
114+
async def async_update(self) -> None:
115+
"""Fetch latest state from light and make available via properties."""
116+
await self._api.poll_state(run_callbacks=True)
117+
118+
async def async_turn_on(self, **kwargs: Any) -> None:
119+
"""Set properties then turn the light on."""
120+
121+
_LOGGER.debug("Turning light %s on with args %s", self.name, kwargs)
122+
123+
if ATTR_BRIGHTNESS in kwargs:
124+
brightness = kwargs[ATTR_BRIGHTNESS]
125+
_LOGGER.debug("Setting brightness of %s to %s", self.name, brightness)
126+
await self._api.set_brightness(brightness)
127+
128+
if ATTR_COLOR_TEMP_KELVIN in kwargs:
129+
color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
130+
mireds = color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)
131+
_LOGGER.debug("Setting color temp of %s to %s", self.name, mireds)
132+
await self._api.set_colour_temp(mireds)
133+
134+
if ATTR_XY_COLOR in kwargs:
135+
xy_color = kwargs[ATTR_XY_COLOR]
136+
_LOGGER.debug("Setting XY color of %s to %s", self.name, xy_color)
137+
await self._api.set_colour_xy(xy_color[0], xy_color[1])
138+
139+
await self._api.set_power(True)
140+
141+
async def async_turn_off(self, **kwargs: Any) -> None:
142+
"""Turn light off then set properties."""
143+
144+
_LOGGER.debug("Turning light %s off with args %s", self.name, kwargs)
145+
await self._api.set_power(False)
146+
147+
@property
148+
def color_mode(self) -> ColorMode:
149+
"""Color mode of the light."""
150+
151+
if self._api.supports_colour_xy and not self._api.colour_temp_mode:
152+
return ColorMode.XY
153+
154+
if self._api.colour_temp_mode:
155+
return ColorMode.COLOR_TEMP
156+
157+
if self._api.supports_brightness:
158+
return ColorMode.BRIGHTNESS
159+
160+
return ColorMode.ONOFF

0 commit comments

Comments
 (0)