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.
- HueBLE-1.0.0.dist-info/LICENSE.txt +21 -0
- HueBLE-1.0.0.dist-info/METADATA +169 -0
- HueBLE-1.0.0.dist-info/RECORD +6 -0
- HueBLE-1.0.0.dist-info/WHEEL +5 -0
- HueBLE-1.0.0.dist-info/top_level.txt +1 -0
- HueBLE.py +1066 -0
|
@@ -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
|
+

|
|
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 @@
|
|
|
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
|