HueBLE 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Harvey Lelliott
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.1
2
+ Name: HueBLE
3
+ Version: 1.0.0
4
+ Summary: Python module for controlling and monitoring Bluetooth Philips Hue bulbs
5
+ Author-email: Harvey Lelliott <harveylelliott@duck.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2024 Harvey Lelliott
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://github.com/flip-dots/HueBLE
28
+ Project-URL: Documentation, https://hueble.readthedocs.io/en/latest/
29
+ Project-URL: Repository, https://github.com/flip-dots/HueBLE
30
+ Project-URL: Issues, https://github.com/flip-dots/HueBLE/issues
31
+ Project-URL: Changelog, https://github.com/flip-dots/HueBLE/CHANGELOG.rst
32
+ Keywords: Philips Hue,BLE,Home Assistant
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: Topic :: Home Automation
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Framework :: AsyncIO
38
+ Classifier: Operating System :: Microsoft :: Windows :: Windows 10
39
+ Classifier: Operating System :: POSIX :: Linux
40
+ Classifier: Operating System :: MacOS :: MacOS X
41
+ Classifier: Programming Language :: Python :: 3.10
42
+ Requires-Python: >=3.10
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE.txt
45
+ Requires-Dist: bleak >=0.19.0
46
+ Requires-Dist: bleak-retry-connector
47
+
48
+ # HueBLE
49
+
50
+ ![HueBLE logo](/hue_ble.png)
51
+
52
+
53
+ Python module for controlling Bluetooth Philips Hue lights.
54
+ - 👌 Free software: MIT license
55
+ - 🖨️ Documentation: https://hueble.readthedocs.io/en/latest/
56
+ - 📦 PIP: https://pypi.org/project/HueBLE/
57
+
58
+
59
+ This Python module enables you to control Philips Hue Bluetooth lights directly
60
+ from your computer, without the need for a Hue bridge or ZigBee dongle.
61
+ It leverages the Bleak library to interact with Bluetooth Philips Hue lights.
62
+
63
+
64
+ ## Features
65
+
66
+ - 💡 On/Off control
67
+ - 🌗 Brightness control
68
+ - 🌡️ Colour temp control
69
+ - 🌈 XY colour control
70
+ - ❔ Light state (power/brightness/temp/colour)
71
+ - ⚙️ Light configuration (name)
72
+ - 📊 Light metadata (manufacturer/model/zigbee address)
73
+ - 🤜 Supports push & polling models
74
+ - 🔂 Simple structure
75
+ - 📜 Mediocre documentation
76
+ - ✔️ More emojis than strictly necessary
77
+
78
+
79
+ ## Requirements
80
+
81
+ - 🐍 Python 3.10+
82
+ - 📶 Bleak 0.19.0+
83
+ - 📶 bleak-retry-connector
84
+
85
+
86
+ ## Supported Operating Systems
87
+
88
+ - 🐧 Linux (BlueZ)
89
+ - Ubuntu Desktop
90
+ - Arch (HomeAssistant OS)
91
+ - 🏢 Windows
92
+ - Windows 10
93
+ - 💾 Mac OSX
94
+ - Maybe?
95
+
96
+
97
+ ## Documentation
98
+
99
+ https://hueble.readthedocs.io/en/latest/
100
+
101
+
102
+ ## Installation
103
+
104
+
105
+ ### PIP
106
+
107
+ ```
108
+ pip install HueBLE
109
+ ```
110
+
111
+
112
+ ### Manual
113
+
114
+ HueBLE consists of a single file (HueBLE.py) which you can simply put in the
115
+ same directory as your program. If you are using manual installation make sure
116
+ the dependencies are installed as well.
117
+
118
+ ```
119
+ pip install bleak bleak-retry-connector
120
+ ```
121
+
122
+
123
+ ## Examples
124
+
125
+
126
+ ### Quick start example
127
+
128
+ Example code from example.py
129
+
130
+ ```python
131
+ import asyncio
132
+ from bleak import BleakScanner
133
+ import HueBLE
134
+
135
+
136
+ async def main():
137
+
138
+ # Address of light to connect to
139
+ address = "F6:9B:48:A4:D2:D8"
140
+
141
+ # Obtain the BLEDevice from bleak
142
+ device = await BleakScanner.find_device_by_address(address)
143
+
144
+ # Initialize the light object
145
+ light = HueBLE.HueBleLight(device)
146
+
147
+ # Optionally we could call connect but it will be called automatically
148
+ # on the first request to the light. You might want to call this if
149
+ # you want to subscribe to state changes without changing the lights state.
150
+ # await light.connect()
151
+
152
+ # Will automatically connect to the light and turn it off
153
+ await light.set_power(False)
154
+
155
+ # Wait
156
+ await asyncio.sleep(5)
157
+
158
+ # Turn the light back on again
159
+ await light.set_power(True)
160
+
161
+ if __name__ == "__main__":
162
+ asyncio.run(main())
163
+
164
+ ```
165
+
166
+
167
+ ### Demo program
168
+
169
+ A more fully featured demo program can be found in ``` examples/demo.py ``` which demonstrates all of the implemented features.
@@ -0,0 +1,6 @@
1
+ HueBLE.py,sha256=WNoRySvJoBrKVS0LF738Co2cT4zcOPnwToGhunDQbrE,40554
2
+ HueBLE-1.0.0.dist-info/LICENSE.txt,sha256=raaXgO4P73KgeaU_mQt0k0u_cR0HlPI9OylrnsLL2tg,1091
3
+ HueBLE-1.0.0.dist-info/METADATA,sha256=jhE0HBqX1wNhKRfT13K0gL24QNkOSRmJXfhWKF_nv2k,5126
4
+ HueBLE-1.0.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
5
+ HueBLE-1.0.0.dist-info/top_level.txt,sha256=Q0gAppI5c5zvjdDEyzeSJY_NkgdQ96v8wYqBS8MBdtg,7
6
+ HueBLE-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ HueBLE
HueBLE.py ADDED
@@ -0,0 +1,1066 @@
1
+ """HueBLE module.
2
+
3
+ .. moduleauthor:: Harvey Lelliott (flip-dots) <harveylelliott@duck.com>
4
+
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import platform
10
+ from bleak import BleakClient, BleakError, BleakScanner
11
+ from bleak.backends.client import BaseBleakClient
12
+ from bleak.backends.device import BLEDevice
13
+ from bleak_retry_connector import establish_connection
14
+ from struct import pack, unpack
15
+ from typing import Callable
16
+
17
+
18
+ #: String containing manufacturer. Handle 15.
19
+ UUID_MANUFACTURER = "00002a29-0000-1000-8000-00805f9b34fb"
20
+
21
+ #: String containing model number. Handle 17
22
+ UUID_MODEL = "00002a24-0000-1000-8000-00805f9b34fb"
23
+
24
+ #: String containing firmware version. Handle 19.
25
+ UUID_FW_VERSION = "00002a28-0000-1000-8000-00805f9b34fb"
26
+
27
+ #: String containing Zigbee address. Handle 22.
28
+ UUID_ZIGBEE_ADDRESS = "97fe6561-0001-4f62-86e9-b71ee2da3d22"
29
+
30
+ #: String containing light name. Handle 24
31
+ UUID_NAME = "97fe6561-0003-4f62-86e9-b71ee2da3d22"
32
+
33
+ #: Power state of light. Is subscribable. x00 and x01. Handle 49.
34
+ UUID_POWER = "932c32bd-0002-47a2-835a-a8d455b859dd"
35
+
36
+ #: Brightness of light. Int 0-255. Handle 52.
37
+ UUID_BRIGHTNESS = "932c32bd-0003-47a2-835a-a8d455b859dd"
38
+
39
+ #: Temperature of light. Int 153-500. 0xFFFF when colour enabled. Handle 55.
40
+ UUID_TEMPERATURE = "932c32bd-0004-47a2-835a-a8d455b859dd"
41
+
42
+ #: XY colour of light. Two 16-bit ints. 0xFFFFFFFF when CW/WW. Handle 58.
43
+ UUID_XY_COLOUR = "932c32bd-0005-47a2-835a-a8d455b859dd"
44
+
45
+ #: This is a UUID that as far as I know only hue lights use and it shows up
46
+ #: under BLE Device details and as such does not require connecting to check
47
+ #: for. The UUID also has the following service data. I am not sure what it
48
+ #: means but it is the same for both of my colour lights with model no LCA006.
49
+ #: \x02\x10\x0e\xbe\x02
50
+ UUID_HUE_IDENTIFIER = "0000fe0f-0000-1000-8000-00805f9b34fb"
51
+
52
+ #: The assumed minimum colour temperature of the light in mireds.
53
+ #: Constant as it us unknown if/where the light exposes this data.
54
+ MIN_MIREDS = 153
55
+
56
+ #: The assumed maximum colour temperature of the light in mireds.
57
+ #: Constant as it us unknown if/where the light exposes this data.
58
+ MAX_MIREDS = 500
59
+
60
+ #: Default string of light metadata light address, model, and firmware.
61
+ DEFAULT_METADATA_STRING = "Unknown"
62
+
63
+ #: Default max connection attempts in the connect function.
64
+ DEFAULT_CONNECTION_ATTEMPTS_MAX = 3
65
+
66
+ #: Default max time for all connection attempts in connect function.
67
+ DEFAULT_CONNECTION_TIMEOUT = 30
68
+
69
+ #: Default max time before a call to connect times out waiting for
70
+ #: another call to connect to finish its attempt.
71
+ DEFAULT_CONNECTION_WAIT_TIMEOUT = 120
72
+
73
+ #: Default time to wait after pairing to check if it was successful.
74
+ DEFAULT_PAIR_DELAY = 1
75
+
76
+ #: Default max time before a call to poll state times out.
77
+ DEFAULT_POLL_STATE_TIMEOUT = 30
78
+
79
+ #: Default max time before a single read attempt to the light times out and it
80
+ #: will try again. This must be large enough to allow for a connection attempt
81
+ #: if the light is not already connected.
82
+ DEFAULT_READ_GATT_TIMEOUT = 15
83
+
84
+ #: Default max light read attempts before raising an exception.
85
+ DEFAULT_READ_GATT_MAX_ATTEMPTS = 3
86
+
87
+ #: Default max time before a single write attempt to the light times out and it
88
+ #: will try again. This must be large enough to allow for a connection attempt
89
+ #: if the light is not already connected.
90
+ DEFAULT_WRITE_GATT_TIMEOUT = 15
91
+
92
+ #: Default max light write attempts before raising an exception.
93
+ DEFAULT_WRITE_GATT_MAX_ATTEMPTS = 3
94
+
95
+ #: Default of all polls to the light updating the light objects internal state.
96
+ DEFAULT_POLL_WRITES_STATE = True
97
+
98
+ #: Default amount of time to scan for a light for in the discovery method.
99
+ DEFAULT_DISCOVER_TIME = 5
100
+
101
+
102
+ _LOGGER = logging.getLogger(__name__)
103
+
104
+
105
+ class HueBleLight(object):
106
+ """Philips Hue BLE Light object."""
107
+
108
+ def __init__(self, ble_device: BLEDevice):
109
+ """Initialise light object. Does not connect automatically."""
110
+
111
+ _LOGGER.debug(
112
+ f"""Initializing Hue light "{ble_device.name}" with"""
113
+ f""" address "{ble_device.address}" and details"""
114
+ f""" "{ble_device.details}"."""
115
+ )
116
+
117
+ self._ble_device = ble_device
118
+ self._client: BleakClient = None
119
+ self._manufacturer = DEFAULT_METADATA_STRING
120
+ self._model = DEFAULT_METADATA_STRING
121
+ self._fw = DEFAULT_METADATA_STRING
122
+ self._zigbee_address = DEFAULT_METADATA_STRING
123
+ self._light_name = DEFAULT_METADATA_STRING
124
+ self._power_on = False
125
+ self._brightness = None
126
+ self._colour_temp = None
127
+ self._minimum_mireds = MIN_MIREDS
128
+ self._maximum_mireds = MAX_MIREDS
129
+ self._colour_xy = None
130
+
131
+ self._state_changed_callbacks: list[Callable[[], None]] = []
132
+ self._expect_disconnect = False
133
+ self._connection_lock: asyncio.Lock = asyncio.Lock()
134
+ self._state_update_lock: asyncio.Lock = asyncio.Lock()
135
+
136
+ def add_callback_on_state_changed(self, function: Callable[[], None]) -> None:
137
+ """Register a callback to be run on light state changes. Triggers
138
+ include changes to power, brightness, temp, colour, and connection
139
+ status. These triggers are not dependant on the request being made
140
+ by this program. (e.g If a ZigBee switch or Hue app commands the
141
+ light off the callback will still be run)
142
+ """
143
+ self._state_changed_callbacks.append(function)
144
+
145
+ def remove_callback(self, function: Callable[[], None]) -> None:
146
+ """Remove a registered state change callback.
147
+ Raises ValueError if does not exist.
148
+ """
149
+ self._state_changed_callbacks.remove(function)
150
+
151
+ def _run_state_changed_callbacks(self) -> None:
152
+ """Executes all registered callbacks for a state change."""
153
+ for function in self._state_changed_callbacks:
154
+ function()
155
+
156
+ def _disconnect_callback(self, client: BaseBleakClient) -> None:
157
+ """Private method. Updates program state and will attempt reconnect
158
+ if the disconnect was unexpected.
159
+ """
160
+ # Warn about weird edge case. This doesn't seem to cause any issues
161
+ # anymore but it's still weird that this happens so im leaving it here
162
+ # to help future me in case it bugs out again.
163
+ if client != self._client:
164
+ _LOGGER.warn(
165
+ f"""The disconnect came from an unexpected client. The class"""
166
+ f""" client is "{self._client}", but the callback"""
167
+ f""" gave us "{client}". Will run disconnect anyway."""
168
+ )
169
+
170
+ # If we expected the disconnect then we don't try to reconnect.
171
+ if self._expect_disconnect:
172
+ _LOGGER.info(f"""Received expected disconnect from "{client}".""")
173
+ return
174
+
175
+ _LOGGER.warn(
176
+ f"""Unexpected disconnect from "{client}"."""
177
+ f""" Will attempt reconnect."""
178
+ )
179
+
180
+ # Run callbacks if we did not expect the disconnect
181
+ self._run_state_changed_callbacks()
182
+
183
+ # Try and reconnect
184
+ asyncio.create_task(self.connect())
185
+
186
+ async def reconnect(self):
187
+ """Disconnects then reconnects to the device.
188
+ Simply calls disconnect then calls connect.
189
+ """
190
+
191
+ _LOGGER.debug(f"""Disconnecting then reconnecting to "{self.address}".""")
192
+
193
+ # There *should* be no harm in calling disconnect even if we are
194
+ # already disconnected. We could call self.connected but
195
+ # it might be possible for that to return inaccurate values
196
+ # if the OS starts being silly. Might make sense to do that in the
197
+ # future though, so heads up future me or other equally cool person.
198
+ await self.disconnect()
199
+ await self.connect()
200
+
201
+ async def _subscribe_to_light(self) -> bool:
202
+ """Subscribes to the state of the light.
203
+ Automatically called on connect.
204
+ Returns true if succeeded.
205
+ """
206
+
207
+ try:
208
+
209
+ # If turning off an on is supported :|
210
+ if self.supports_on_off:
211
+
212
+ def report(cHandle: int, data: bytearray) -> None:
213
+ self._power_on = bool(data[0])
214
+ _LOGGER.debug(
215
+ f"""Light "{self.name}" has informed us of a new"""
216
+ f""" power state of ({self.power_state})"""
217
+ )
218
+ self._run_state_changed_callbacks()
219
+
220
+ _LOGGER.debug("Subscribing to power state UUID")
221
+ await self._client.start_notify(UUID_POWER, report)
222
+
223
+ # If brightness is supported
224
+ if self.supports_brightness:
225
+
226
+ def report(cHandle: int, data: bytearray) -> None:
227
+ self._brightness = data[0]
228
+ _LOGGER.debug(
229
+ f"""Light "{self.name}" has informed us of a new"""
230
+ f""" brightness state of ({self.brightness})"""
231
+ )
232
+ self._run_state_changed_callbacks()
233
+
234
+ _LOGGER.debug("Subscribing to brightness UUID")
235
+ await self._client.start_notify(UUID_BRIGHTNESS, report)
236
+
237
+ # If colour temperature is supported
238
+ if self.supports_colour_temp:
239
+
240
+ def report(cHandle: int, data: bytearray) -> None:
241
+ self._colour_temp = int.from_bytes(data, "little")
242
+ _LOGGER.debug(
243
+ f"""Light "{self.name}" has informed us of a new"""
244
+ f""" colour temp state of ({self.colour_temp})"""
245
+ )
246
+ self._run_state_changed_callbacks()
247
+
248
+ _LOGGER.debug("Subscribing to colour temperature UUID")
249
+ await self._client.start_notify(UUID_TEMPERATURE, report)
250
+
251
+ # If XY colour is supported
252
+ if self.supports_colour_xy:
253
+
254
+ def report(cHandle: int, data: bytearray) -> None:
255
+ x, y = unpack("<HH", data)
256
+ self._colour_xy = (x / 0xFFFF, y / 0xFFFF)
257
+ _LOGGER.debug(
258
+ f"""Light "{self.name}" has informed us of a new"""
259
+ f""" XY colour state of ({self.colour_xy})"""
260
+ )
261
+ self._run_state_changed_callbacks()
262
+
263
+ _LOGGER.debug("Subscribing to XY colour UUID")
264
+ await self._client.start_notify(UUID_XY_COLOUR, report)
265
+
266
+ return True
267
+
268
+ except Exception as e:
269
+ _LOGGER.error(
270
+ f"""Failed to subscribe to services of light "{self.name}"."""
271
+ f""" Error: "{e}"."""
272
+ )
273
+ return False
274
+
275
+ async def connect(
276
+ self,
277
+ max_attempts: int = DEFAULT_CONNECTION_ATTEMPTS_MAX,
278
+ connection_timeout: int = DEFAULT_CONNECTION_TIMEOUT,
279
+ wait_timeout: int = DEFAULT_CONNECTION_WAIT_TIMEOUT,
280
+ ) -> bool:
281
+ """Connects to the light using bluetooth. This can be manually called
282
+ but it will also be run automatically if the light is not
283
+ connected when the light is polled or set.
284
+
285
+ On connection it will attempt to pair to the light if not already
286
+ paired but this only seems to work on Linux, so you might need to
287
+ pair using your OS.
288
+
289
+ Connecting will NOT automatically cause all values to be polled
290
+ and put in the internal state.
291
+
292
+ Connection timeout is the maximum time for a connection attempt
293
+ once a lock has been acquired.
294
+
295
+ Wait timeout is the maximum amount of time that the method will
296
+ wait to acquire a lock to attempt to perform a connection.
297
+ """
298
+
299
+ # If we call connect we do not expect a disconnect
300
+ self._expect_disconnect = False
301
+
302
+ # If we are already connected then do nothing
303
+ if self.connected:
304
+ _LOGGER.debug(f"""Already connected to "{self.name}" Nothing to do here.""")
305
+ return True
306
+
307
+ # Print warning that the connection is already in progress by
308
+ # another method.
309
+ if self._connection_lock.locked():
310
+ _LOGGER.debug(
311
+ f"""Connection already in progress to "{self.name}"."""
312
+ f""" Waiting for it to complete..."""
313
+ )
314
+
315
+ # Timeout if waiting too long
316
+ # Idea is to prevent a large queue from forming
317
+ try:
318
+ async with asyncio.timeout(wait_timeout):
319
+
320
+ # Use a lock to prevent the library from trying to connect
321
+ # to the same light at the same time from different methods.
322
+ async with self._connection_lock:
323
+
324
+ # If we are now connected after waiting for the lock to
325
+ # release then do nothing.
326
+ if self.connected:
327
+ _LOGGER.debug(
328
+ f"""Now connected to "{self.name}" after waiting"""
329
+ f""" for lock release"""
330
+ )
331
+ return True
332
+
333
+ _LOGGER.debug(
334
+ f"""Connecting to "{self.name}" with address"""
335
+ f""" "{self.address}"."""
336
+ )
337
+
338
+ # Maximum time to make a connection
339
+ try:
340
+ async with asyncio.timeout(connection_timeout):
341
+
342
+ # Make a fresh bleak client and connect
343
+ self._client = await establish_connection(
344
+ BleakClient,
345
+ device=self._ble_device,
346
+ name=self.address,
347
+ max_attempts=max_attempts,
348
+ disconnected_callback=self._disconnect_callback,
349
+ )
350
+
351
+ _LOGGER.debug(
352
+ f"""Using client "{self._client}" with """
353
+ f"""backend "{self._client._backend}"."""
354
+ )
355
+
356
+ # If we failed to connect
357
+ if not self._client.is_connected:
358
+ _LOGGER.error(
359
+ f"""Failed to connect to "{self.name}"."""
360
+ )
361
+ if _LOGGER.isEnabledFor(_LOGGER.debug):
362
+ await self.print_services()
363
+ return False
364
+
365
+ # Check if the light is paired and trusted
366
+ # If we are not using a Linux system then we don't
367
+ # know if we are paired with the device.
368
+ # We assume we are not paired and attempt to pair.
369
+ authenticated = self.authenticated
370
+
371
+ # If we dont know if we are paired and trusted then
372
+ # pair and assume it worked (non Linux systems)
373
+ if authenticated is None:
374
+
375
+ # Pair. If not successful return false
376
+ if not await self.pair():
377
+ _LOGGER.error(
378
+ f"""Failed paring to "{self.name}"."""
379
+ )
380
+ return False
381
+
382
+ # If we are not paired then pair
383
+ elif not authenticated:
384
+
385
+ # Pair. If not successful return false
386
+ if not await self.pair():
387
+ _LOGGER.error(
388
+ f"""Failed paring to "{self.name}"."""
389
+ )
390
+ return False
391
+
392
+ # If we have reached here that means connecting and
393
+ # pairing was (probably) successful
394
+ _LOGGER.debug(
395
+ f"""Successfully connected and authenticated"""
396
+ f""" to "{self.name}"."""
397
+ )
398
+
399
+ # If we cant find out what the light supports
400
+ # then we have failed
401
+ if not await self._determine_services():
402
+ return False
403
+
404
+ # Inform of subscription attempt
405
+ _LOGGER.debug(
406
+ f"""Attempting to subscribe to services"""
407
+ f""" offered by light "{self.name}"."""
408
+ )
409
+
410
+ # If subscribing was successful then we are good!
411
+ return await self._subscribe_to_light()
412
+
413
+ except asyncio.TimeoutError as e:
414
+ _LOGGER.error(
415
+ f"""Timed out attempting to connect to"""
416
+ f""" "{self.name}". Error "{e}"."""
417
+ )
418
+
419
+ except asyncio.TimeoutError as e:
420
+ _LOGGER.error(
421
+ f"""Timed out waiting for connection lock for"""
422
+ f""" "{self.name}". Error "{e}"."""
423
+ )
424
+
425
+ return False
426
+
427
+ async def pair(self) -> bool:
428
+ """Attempts to pair to the light and returns the result.
429
+ Return value defaults to true on non-Linux systems.
430
+ """
431
+
432
+ _LOGGER.debug(f"""Attempting to pair to "{self.name}".""")
433
+
434
+ # If we are not connected then we cannot send a pair request
435
+ if not self.connected:
436
+ _LOGGER.error(f"""Not connected to "{self.name}", therefore cannot pair.""")
437
+ return False
438
+
439
+ try:
440
+ # Request pair using bleak
441
+ await self._client.pair()
442
+ # Wait a little
443
+ await asyncio.sleep(DEFAULT_PAIR_DELAY)
444
+
445
+ # If we are still connected
446
+ if self.connected:
447
+
448
+ authenticated = self.authenticated
449
+ # If we do not know the result of pairing due to
450
+ # to the platform then assume all is well
451
+ if authenticated is None:
452
+ return True
453
+ else:
454
+ return authenticated
455
+
456
+ except asyncio.TimeoutError:
457
+ _LOGGER.error(f"""Timed out attempting to pair to "{self.name}".""")
458
+ except BleakError as err:
459
+ _LOGGER.error(
460
+ f"""Unexpected error attempting to pair to "{self.name}"."""
461
+ f""" Error message "{err}" """
462
+ )
463
+ return False
464
+
465
+ async def _determine_services(self) -> bool:
466
+ """Determines what features the light supports.
467
+ The data may then be retrieved via the supports properties.
468
+ Returns True if successful and False on error.
469
+ """
470
+
471
+ try:
472
+ services = self._client.services
473
+ for id, service in services.services.items():
474
+
475
+ _LOGGER.debug(
476
+ f"Service: {service}, " f"description: {service.description}"
477
+ )
478
+
479
+ if service.characteristics is None:
480
+ continue
481
+
482
+ for char in service.characteristics:
483
+ _LOGGER.debug(
484
+ f"Characteristic: {char}, "
485
+ f"description: {char.description}, "
486
+ f"descriptors: {char.descriptors}"
487
+ )
488
+
489
+ if services.get_characteristic(UUID_POWER) is not None:
490
+ self._power_on = False
491
+ else:
492
+ _LOGGER.error(
493
+ f"""Light "{self.name}" does not appear to """
494
+ f"""support turning on and off."""
495
+ )
496
+ self._power_on = None
497
+
498
+ if services.get_characteristic(UUID_BRIGHTNESS) is not None:
499
+ self._brightness = 0
500
+ else:
501
+ _LOGGER.debug(
502
+ f"""Light "{self.name}" does not appear to """
503
+ f"""support changing the brightness."""
504
+ )
505
+ self._brightness = None
506
+
507
+ if services.get_characteristic(UUID_TEMPERATURE) is not None:
508
+ self._colour_temp = 0
509
+ else:
510
+ _LOGGER.debug(
511
+ f"""Light "{self.name}" does not appear to """
512
+ f"""support changing the colour temp."""
513
+ )
514
+ self._colour_temp = None
515
+
516
+ if services.get_characteristic(UUID_XY_COLOUR) is not None:
517
+ self._colour_xy = (0.0, 0.0)
518
+ else:
519
+ _LOGGER.debug(
520
+ f"""Light "{self.name}" does not appear to """
521
+ f"""support changing the XY colour."""
522
+ )
523
+ self._colour_xy = None
524
+
525
+ return True
526
+
527
+ except BleakError:
528
+ _LOGGER.error(
529
+ f"""Unable to determine what features light"""
530
+ f""" "{self.name}" supports."""
531
+ )
532
+ return False
533
+
534
+ async def disconnect(self) -> None:
535
+ """Disconnects the program from the light.
536
+ Callbacks are not triggered.
537
+ """
538
+
539
+ self._expect_disconnect = True
540
+ # If the client does not exist return
541
+ if self._client is None:
542
+ return
543
+ try:
544
+ await self._client.disconnect()
545
+ except asyncio.TimeoutError:
546
+ _LOGGER.error(f"""Timeout attempting to disconnect from "{self.name}".""")
547
+ except BleakError as err:
548
+ _LOGGER.error(
549
+ f"""Error attempting to disconnect from "{self.name}"."""
550
+ f""" Error message "{err}"."""
551
+ )
552
+
553
+ # Throw away the client
554
+ self._client = None
555
+
556
+ async def poll_state(
557
+ self, timeout=DEFAULT_POLL_STATE_TIMEOUT, run_callbacks=True
558
+ ) -> tuple[bool, list[Exception]]:
559
+ """Updates the local state with values from the light using polling.
560
+ A lock is used to only allow one state update at a time with
561
+ a timeout. Any callbacks are run outside of the state lock but
562
+ within the timeout lock. asyncio.TimeoutError raised on timeout.
563
+ Returns true/false if the state changed and a list of exceptions.
564
+ """
565
+
566
+ state_changed = False
567
+ exceptions = []
568
+
569
+ if self._state_update_lock.locked():
570
+
571
+ _LOGGER.debug(
572
+ f"""Waiting for state update lock of "{self.name}" to be"""
573
+ f""" released."""
574
+ )
575
+
576
+ # Timeout if waiting too long
577
+ async with asyncio.timeout(timeout):
578
+
579
+ # Only one device may poll at a time
580
+ async with self._state_update_lock:
581
+
582
+ _LOGGER.debug(
583
+ f"""Processing state update request for""" f""" "{self.name}"."""
584
+ )
585
+
586
+ # Exceptions are added to the log and a list of them is
587
+ # returned by the method but they are otherwise ignored.
588
+ try:
589
+ prev = self.manufacturer
590
+ if prev != await self.poll_manufacturer(write_state=True):
591
+ state_changed = True
592
+ except Exception as e:
593
+ exceptions.append(e)
594
+ try:
595
+ prev = self.model
596
+ if prev != await self.poll_model(write_state=True):
597
+ state_changed = True
598
+ except Exception as e:
599
+ exceptions.append(e)
600
+ try:
601
+ prev = self.firmware
602
+ if prev != await self.poll_firmware(write_state=True):
603
+ state_changed = True
604
+ except Exception as e:
605
+ exceptions.append(e)
606
+ try:
607
+ prev = self.zigbee_address
608
+ if prev != await self.poll_zigbee_address(write_state=True):
609
+ state_changed = True
610
+ except Exception as e:
611
+ exceptions.append(e)
612
+ try:
613
+ prev = self._light_name
614
+ if prev != await self.poll_light_name(write_state=True):
615
+ state_changed = True
616
+ except Exception as e:
617
+ exceptions.append(e)
618
+ try:
619
+ prev = self.power_state
620
+ if prev != await self.poll_power_state(write_state=True):
621
+ state_changed = True
622
+ except Exception as e:
623
+ exceptions.append(e)
624
+ try:
625
+ prev = self.brightness
626
+ if prev != await self.poll_brightness(write_state=True):
627
+ state_changed = True
628
+ except Exception as e:
629
+ exceptions.append(e)
630
+ try:
631
+ prev = self.colour_temp
632
+ if prev != await self.poll_colour_temp(write_state=True):
633
+ state_changed = True
634
+ except Exception as e:
635
+ exceptions.append(e)
636
+ try:
637
+ prev = self.colour_xy
638
+ if prev != await self.poll_colour_xy(write_state=True):
639
+ state_changed = True
640
+ except Exception as e:
641
+ exceptions.append(e)
642
+
643
+ # Print it all out for debugging
644
+ _LOGGER.debug(
645
+ f"""Data from light "{self.name}"\n"""
646
+ f"""MAC address "{self.address}"\n"""
647
+ f"""Name in Hue app: "{self.name_in_app}"\n"""
648
+ f"""Manufacturer: "{self.manufacturer}"\n"""
649
+ f"""Model: "{self.model}"\n"""
650
+ f"""Firmware: "{self.firmware}"\n"""
651
+ f"""ZigBee Address: "{self.zigbee_address}"\n"""
652
+ f"""Power state: "{self.power_state}"\n"""
653
+ f"""Brightness: "{self.brightness}"\n"""
654
+ f"""Colour temp: "{self.colour_temp}"\n"""
655
+ f"""Colour XY: "{self.colour_xy}"."""
656
+ )
657
+
658
+ # Callbacks are run outside of the state update lock
659
+ if run_callbacks and state_changed:
660
+ self._run_state_changed_callbacks()
661
+
662
+ return state_changed, exceptions
663
+
664
+ async def _read_gatt(
665
+ self,
666
+ property: str,
667
+ attempt_timeout: int = DEFAULT_READ_GATT_TIMEOUT,
668
+ max_attempts: int = DEFAULT_READ_GATT_MAX_ATTEMPTS,
669
+ ) -> bytearray:
670
+ """Reads a GATT attribute from the light.
671
+ If there is an error an exception will be thrown.
672
+ """
673
+ for i in range(1, max_attempts + 1):
674
+ try:
675
+ async with asyncio.timeout(attempt_timeout):
676
+ if not self.connected:
677
+ if not await self.connect():
678
+ raise Exception("Cannot connect!")
679
+ return await self._client.read_gatt_char(property)
680
+
681
+ except Exception as e:
682
+ _LOGGER.debug(
683
+ f"""Failed to read value from "{self.name}" on attempt"""
684
+ f""" {i}/{max_attempts}. Error message "{e}"."""
685
+ )
686
+ raise Exception(
687
+ f"""Unable to read from "{self.name}" after"""
688
+ f""" {max_attempts} attempts"""
689
+ )
690
+
691
+ async def _write_gatt(
692
+ self,
693
+ property: str,
694
+ data: bytes,
695
+ attempt_timeout: int = DEFAULT_WRITE_GATT_TIMEOUT,
696
+ max_attempts: int = DEFAULT_WRITE_GATT_MAX_ATTEMPTS,
697
+ ) -> bytearray:
698
+ """Writes a GATT attribute to a light.
699
+ Will attempt to connect if not already connected.
700
+ Will throw an exception on error.
701
+ """
702
+ for i in range(1, max_attempts + 1):
703
+ try:
704
+ async with asyncio.timeout(attempt_timeout):
705
+ if not self.connected:
706
+ if not await self.connect():
707
+ raise Exception("Cannot connect!")
708
+ return await self._client.write_gatt_char(
709
+ property, data, response=True
710
+ )
711
+
712
+ except Exception as e:
713
+ _LOGGER.debug(
714
+ f"""Failed to write value to "{self.name}" on attempt"""
715
+ f""" {i}/{max_attempts}. Error message "{e}"."""
716
+ )
717
+ raise Exception(
718
+ f"""Unable to write to "{self.name}" after"""
719
+ f""" {max_attempts} attempts"""
720
+ )
721
+
722
+ async def poll_manufacturer(
723
+ self, write_state: bool = DEFAULT_POLL_WRITES_STATE
724
+ ) -> str:
725
+ """Returns the manufacturer of the light."""
726
+ encoded = await self._read_gatt(UUID_MANUFACTURER)
727
+ decoded = encoded.decode("ascii", "ignore")
728
+ if write_state:
729
+ self._manufacturer = decoded
730
+ return decoded
731
+
732
+ async def poll_model(self, write_state: bool = DEFAULT_POLL_WRITES_STATE) -> str:
733
+ """Returns the model of the light."""
734
+ encoded = await self._read_gatt(UUID_MODEL)
735
+ decoded = encoded.decode("ascii", "ignore")
736
+ if write_state:
737
+ self._model = decoded
738
+ return decoded
739
+
740
+ async def poll_firmware(self, write_state: bool = DEFAULT_POLL_WRITES_STATE) -> str:
741
+ """Returns the firmware version of the light."""
742
+ encoded = await self._read_gatt(UUID_FW_VERSION)
743
+ decoded = encoded.decode("ascii", "ignore")
744
+ if write_state:
745
+ self._fw = decoded
746
+ return decoded
747
+
748
+ async def poll_zigbee_address(
749
+ self, write_state: bool = DEFAULT_POLL_WRITES_STATE
750
+ ) -> str:
751
+ """Returns the Zigbee address of the light."""
752
+ encoded = await self._read_gatt(UUID_ZIGBEE_ADDRESS)
753
+ decoded = encoded.hex(sep=":")
754
+ if write_state:
755
+ self._zigbee_address = decoded
756
+ return decoded
757
+
758
+ async def poll_light_name(
759
+ self, write_state: bool = DEFAULT_POLL_WRITES_STATE
760
+ ) -> str:
761
+ """Returns the name of the light as shown in the Hue app.
762
+ This may differ from the light objects name property.
763
+ """
764
+ encoded = await self._read_gatt(UUID_NAME)
765
+ decoded = encoded.decode("ascii", "ignore")
766
+ if write_state:
767
+ self._light_name = decoded
768
+ return decoded
769
+
770
+ async def poll_power_state(
771
+ self, write_state: bool = DEFAULT_POLL_WRITES_STATE
772
+ ) -> bool:
773
+ """Gets the current power state of the light."""
774
+ encoded = await self._read_gatt(UUID_POWER)
775
+ decoded = bool(encoded[0])
776
+ if write_state:
777
+ self._power_on = decoded
778
+ return decoded
779
+
780
+ async def poll_brightness(
781
+ self, write_state: bool = DEFAULT_POLL_WRITES_STATE
782
+ ) -> int:
783
+ """Gets the current brightness as an integer between 0 and 255."""
784
+ encoded = await self._read_gatt(UUID_BRIGHTNESS)
785
+ decoded = encoded[0]
786
+ if write_state:
787
+ self._brightness = decoded
788
+ return decoded
789
+
790
+ async def poll_colour_temp(
791
+ self, write_state: bool = DEFAULT_POLL_WRITES_STATE
792
+ ) -> int:
793
+ """Gets the current colour temperature as an integer
794
+ between 153 and 500. Uses mireds.
795
+ """
796
+ encoded = await self._read_gatt(UUID_TEMPERATURE)
797
+ decoded = int.from_bytes(encoded, "little")
798
+ if write_state:
799
+ self._colour_temp = decoded
800
+ return decoded
801
+
802
+ async def poll_colour_xy(
803
+ self, write_state: bool = DEFAULT_POLL_WRITES_STATE
804
+ ) -> tuple[float, float]:
805
+ """Gets the XY colour coordinates as floats between 0.0 and 1.0."""
806
+ buf = await self._read_gatt(UUID_XY_COLOUR)
807
+ x, y = unpack("<HH", buf)
808
+ x_after = x / 0xFFFF
809
+ y_after = y / 0xFFFF
810
+ if write_state:
811
+ self._colour_xy = (x_after, y_after)
812
+ return x_after, y_after
813
+
814
+ async def set_light_name(self, name: str):
815
+ """Sets the name of the light. Not tested, use at own risk."""
816
+ await self._write_gatt(UUID_NAME, str.encode(name))
817
+
818
+ async def set_power(self, on: bool):
819
+ """Sets the power state of the light."""
820
+ await self._write_gatt(UUID_POWER, bytes([1 if on else 0]))
821
+
822
+ async def set_brightness(self, brightness: int):
823
+ """Sets the brightness from an integer between 0 and 255"""
824
+ await self._write_gatt(UUID_BRIGHTNESS, bytes([max(min(brightness, 254), 1)]))
825
+
826
+ async def set_colour_temp(self, colour_temp: int):
827
+ """Sets the temperature from an int between 153 and 500.
828
+ Uses mireds."""
829
+ temp = max(min(int(colour_temp), 500), 153)
830
+ y = temp.to_bytes(2, "little")
831
+ await self._write_gatt(UUID_TEMPERATURE, y)
832
+
833
+ async def set_colour_xy(self, x: float, y: float):
834
+ """Sets the XY colour coordinates from floats between 0.0 and 1.0."""
835
+ buf = pack("<HH", int(x * 0xFFFF), int(y * 0xFFFF))
836
+ await self._write_gatt(UUID_XY_COLOUR, buf)
837
+
838
+ @property
839
+ def connected(self) -> bool:
840
+ """Returns true if the client is connected to a light.
841
+ This does not however mean that we are able to send
842
+ and receive commands. Use the "available" property for that.
843
+ """
844
+ return self._client and self._client.is_connected
845
+
846
+ @property
847
+ def authenticated(self) -> bool | None:
848
+ """Returns true if the light is paired and trusted.
849
+ This only works on Linux. On other platforms this
850
+ returns None.
851
+ """
852
+
853
+ # If the platform is not Linux then as far as I know there is
854
+ # not an easy way to know if we are paired so we will try to
855
+ # pair even if we are already paired. And if that pairing fails
856
+ # we will not know until we try to read/write to/from the light
857
+ # and get a permission error. Im probably missing something
858
+ # so I should probably take another look in the future.
859
+ if platform.system() != "Linux":
860
+ return None
861
+
862
+ # Get Linux specific properties of our connection
863
+ properties = self._ble_device.details.get("props")
864
+
865
+ # If we are paired and trusted by the light we are good
866
+ if properties.get("Paired") is True:
867
+ if properties.get("Trusted") is True:
868
+ return True
869
+
870
+ else:
871
+ _LOGGER.error(
872
+ f"""Not paired to "{self.name}". Device""" f""" not trusted."""
873
+ )
874
+
875
+ else:
876
+ _LOGGER.error(
877
+ f"""Not paired to "{self.name}".""" f""" Device was not paired."""
878
+ )
879
+
880
+ return False
881
+
882
+ @property
883
+ def available(self) -> bool:
884
+ """Is the light connected and authenticated.
885
+ Meaning are we able to send/receive commands to/from the light.
886
+ On non-Linux systems it is assumed we are authenticated!.
887
+ """
888
+ if self.connected:
889
+ authenticated = self.authenticated
890
+
891
+ # If we do not know the auth status assume it is ok
892
+ if authenticated is None:
893
+ return True
894
+ else:
895
+ # If we are authenticated then we may send/receive commands
896
+ return authenticated
897
+ return False
898
+
899
+ @property
900
+ def address(self) -> str:
901
+ """MAC address of the light."""
902
+ return self._ble_device.address
903
+
904
+ @property
905
+ def manufacturer(self) -> str:
906
+ """Manufacturer of the light."""
907
+ return self._manufacturer
908
+
909
+ @property
910
+ def model(self) -> str:
911
+ """Model of the light."""
912
+ return self._model
913
+
914
+ @property
915
+ def firmware(self) -> str:
916
+ """Firmware of the light."""
917
+ return self._fw
918
+
919
+ @property
920
+ def zigbee_address(self) -> str:
921
+ """Zigbee address of the light."""
922
+ return self._zigbee_address
923
+
924
+ @property
925
+ def name(self) -> str:
926
+ """Bluetooth name of the light.
927
+ Should not but may differ from value from poll_light_name().
928
+ """
929
+ return self._ble_device.name
930
+
931
+ @property
932
+ def name_in_app(self) -> str:
933
+ """Name of light in Hue app.
934
+ Should not but may differ from the name (bluetooth) property.
935
+ """
936
+ return self._light_name
937
+
938
+ @property
939
+ def power_state(self) -> bool | None:
940
+ """Is the light running?, you better go catch it."""
941
+ return self._power_on
942
+
943
+ @property
944
+ def brightness(self) -> int | None:
945
+ """Brightness of the light. Int 0-255.
946
+ Returns None if the feature is not supported by the light.
947
+ """
948
+ if self.supports_brightness:
949
+ return self._brightness
950
+ else:
951
+ return None
952
+
953
+ @property
954
+ def colour_temp(self) -> int | None:
955
+ """Colour temperature of the light in mireds. Int 153-500.
956
+ Returns None if the feature is not supported by the light.
957
+ """
958
+ if self.supports_colour_temp:
959
+ return self._colour_temp
960
+ else:
961
+ return None
962
+
963
+ @property
964
+ def minimum_mireds(self) -> int | None:
965
+ """Minimum mireds colour temperature supported.
966
+ Returns None if the feature is not supported by the light.
967
+ This value is assumed and not actually polled from the light.
968
+ """
969
+ if self.supports_colour_temp:
970
+ return self._minimum_mireds
971
+ else:
972
+ return None
973
+
974
+ @property
975
+ def maximum_mireds(self) -> int | None:
976
+ """Maximum mireds colour temperature supported.
977
+ Returns None if the feature is not supported by the light.
978
+ This value is assumed and not actually polled from the light.
979
+ """
980
+ if self.supports_colour_temp:
981
+ return self._maximum_mireds
982
+ else:
983
+ return None
984
+
985
+ @property
986
+ def colour_xy(self) -> tuple[int, int] | None:
987
+ """Colour in XY coordinates. (0.0, 0.0) - (1.0, 1.0).
988
+ Returns None if the feature is not supported by the light.
989
+ """
990
+ if self.supports_colour_xy:
991
+ return self._colour_xy
992
+ else:
993
+ return None
994
+
995
+ @property
996
+ def colour_temp_mode(self) -> bool | None:
997
+ """True if the light is in colour temperature mode, else false.
998
+ Returns None if the feature is not supported by the light.
999
+ """
1000
+ # If the light does not support colour temp
1001
+ # it can't be in colour temp mode
1002
+ if not self.supports_colour_temp:
1003
+ return False
1004
+
1005
+ # If the light does support temperature but does not support
1006
+ # XY colour then it must be in colour temperature mode.
1007
+ elif not self.supports_colour_xy:
1008
+ return True
1009
+
1010
+ # Else the light supports colour temperature and XY colour
1011
+ # The light is in colour temperature mode when the XY colour
1012
+ # is (0.0, 0.0) or (1.0, 1.0)
1013
+ else:
1014
+ return self._colour_xy == (0.0, 0.0) or self._colour_xy == (1.0, 1.0)
1015
+
1016
+ @property
1017
+ def supports_on_off(self) -> bool:
1018
+ """Does the light support turning on and off.
1019
+ Yes I know it's silly but its for forwards compatibility
1020
+ in case a bluetooth sensor is ever released.
1021
+ """
1022
+ return self._power_on is not None
1023
+
1024
+ @property
1025
+ def supports_brightness(self) -> bool:
1026
+ """Does the light support brightness control."""
1027
+ return self._brightness is not None
1028
+
1029
+ @property
1030
+ def supports_colour_temp(self) -> bool:
1031
+ """Does the light support colour temperature control."""
1032
+ return self._colour_temp is not None
1033
+
1034
+ @property
1035
+ def supports_colour_xy(self) -> bool:
1036
+ """Does the light support XY (RGB) colour control."""
1037
+ return self._colour_xy is not None
1038
+
1039
+
1040
+ async def discover_lights(
1041
+ scanner: BleakScanner | None = None, timeout: int = DEFAULT_DISCOVER_TIME
1042
+ ) -> list[BLEDevice]:
1043
+ """Scanning feature
1044
+ Scans the BLE neighborhood for Hue BLE light(s) and returns
1045
+ a list of nearby lights based upon detection of a known UUID.
1046
+ """
1047
+
1048
+ if scanner is None:
1049
+ scanner = BleakScanner
1050
+
1051
+ devices = []
1052
+
1053
+ # Callback for when advertising data is received
1054
+ def callback(device, advertising_data):
1055
+ if (
1056
+ UUID_HUE_IDENTIFIER in advertising_data.service_uuids
1057
+ and device not in devices
1058
+ ):
1059
+ devices.append(device)
1060
+
1061
+ # Scan for timeout seconds
1062
+ async with BleakScanner(callback) as scanner:
1063
+ await asyncio.sleep(timeout)
1064
+
1065
+ # Return what we found
1066
+ return devices