phue2 0.0.1.dev279__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.
- phue2/__init__.py +37 -0
- phue2/__main__.py +408 -0
- phue2/_internal/__init__.py +1 -0
- phue2/_internal/console.py +175 -0
- phue2/bridge.py +1246 -0
- phue2/exceptions.py +22 -0
- phue2/group.py +118 -0
- phue2/light.py +301 -0
- phue2/scene.py +58 -0
- phue2/sensor.py +307 -0
- phue2-0.0.1.dev279.dist-info/METADATA +188 -0
- phue2-0.0.1.dev279.dist-info/RECORD +15 -0
- phue2-0.0.1.dev279.dist-info/WHEEL +4 -0
- phue2-0.0.1.dev279.dist-info/entry_points.txt +2 -0
- phue2-0.0.1.dev279.dist-info/licenses/LICENSE +21 -0
phue2/bridge.py
ADDED
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
"""Bridge class for controlling Philips Hue bridges."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
from collections.abc import Hashable, Iterable
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from phue2.exceptions import (
|
|
13
|
+
PhueException,
|
|
14
|
+
PhueRegistrationException,
|
|
15
|
+
PhueRequestTimeout,
|
|
16
|
+
)
|
|
17
|
+
from phue2.group import Group
|
|
18
|
+
from phue2.light import Light
|
|
19
|
+
from phue2.scene import Scene
|
|
20
|
+
from phue2.sensor import Sensor
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("phue_modern")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Bridge:
|
|
26
|
+
"""Interface to the Hue ZigBee bridge
|
|
27
|
+
|
|
28
|
+
You can obtain Light objects by calling the get_light_objects method:
|
|
29
|
+
|
|
30
|
+
>>> b = Bridge(ip='192.168.1.100')
|
|
31
|
+
>>> b.get_light_objects()
|
|
32
|
+
[<phue_modern.Light at 0x10473d750>,
|
|
33
|
+
<phue_modern.Light at 0x1046ce110>]
|
|
34
|
+
|
|
35
|
+
Or more succinctly just by accessing this Bridge object as a list or dict:
|
|
36
|
+
|
|
37
|
+
>>> b[1]
|
|
38
|
+
<phue_modern.Light at 0x10473d750>
|
|
39
|
+
>>> b['Kitchen']
|
|
40
|
+
<phue_modern.Light at 0x10473d750>
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
ip: str | None = None,
|
|
46
|
+
username: str | None = None,
|
|
47
|
+
config_file_path: str | None = None,
|
|
48
|
+
timeout: int = 10,
|
|
49
|
+
):
|
|
50
|
+
"""Initialization function.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
ip: IP address as dotted quad
|
|
54
|
+
username: Optional username for the bridge
|
|
55
|
+
config_file_path: Optional path to the configuration file
|
|
56
|
+
timeout: Request timeout in seconds (default: 10)
|
|
57
|
+
"""
|
|
58
|
+
# Determine config file path
|
|
59
|
+
if config_file_path is not None:
|
|
60
|
+
self.config_file_path = config_file_path
|
|
61
|
+
else:
|
|
62
|
+
# Define the home environment variable name based on platform
|
|
63
|
+
if platform.system() == "Windows":
|
|
64
|
+
user_home_env_var = "USERPROFILE"
|
|
65
|
+
else:
|
|
66
|
+
user_home_env_var = "HOME"
|
|
67
|
+
|
|
68
|
+
# Get the actual home path using the correct env var name
|
|
69
|
+
user_home_path = os.getenv(user_home_env_var)
|
|
70
|
+
if user_home_path is not None and os.access(user_home_path, os.W_OK):
|
|
71
|
+
# Use user home path if writable
|
|
72
|
+
self.config_file_path = os.path.join(user_home_path, ".python_hue")
|
|
73
|
+
elif (
|
|
74
|
+
(
|
|
75
|
+
"iPad" in platform.machine()
|
|
76
|
+
or "iPhone" in platform.machine()
|
|
77
|
+
or "iPod" in platform.machine()
|
|
78
|
+
)
|
|
79
|
+
and user_home_path is not None # Check user_home_path exists here too
|
|
80
|
+
):
|
|
81
|
+
# Use Documents directory on iOS-like platforms
|
|
82
|
+
self.config_file_path = os.path.join(
|
|
83
|
+
user_home_path, "Documents", ".python_hue"
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
# Fallback to current working directory
|
|
87
|
+
self.config_file_path = os.path.join(os.getcwd(), ".python_hue")
|
|
88
|
+
|
|
89
|
+
self.ip = ip
|
|
90
|
+
self._username = username
|
|
91
|
+
self.timeout = timeout
|
|
92
|
+
self.lights_by_id: dict[int, Light] = {}
|
|
93
|
+
self.lights_by_name: dict[str, Light] = {}
|
|
94
|
+
self.sensors_by_id: dict[int, Sensor] = {}
|
|
95
|
+
self.sensors_by_name: dict[str, Sensor] = {}
|
|
96
|
+
self._name: str | None = None
|
|
97
|
+
|
|
98
|
+
self.connect()
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def username(self) -> str:
|
|
102
|
+
assert self._username is not None
|
|
103
|
+
return self._username
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def name(self) -> str:
|
|
107
|
+
"""Get or set the name of the bridge [string]"""
|
|
108
|
+
name = self.request("GET", "/api/" + self.username + "/config")["name"]
|
|
109
|
+
self._name = name
|
|
110
|
+
return name
|
|
111
|
+
|
|
112
|
+
@name.setter
|
|
113
|
+
def name(self, value: str) -> None:
|
|
114
|
+
self._name = value
|
|
115
|
+
data = {"name": self._name}
|
|
116
|
+
self.request("PUT", "/api/" + self.username + "/config", data)
|
|
117
|
+
|
|
118
|
+
def request(
|
|
119
|
+
self,
|
|
120
|
+
method: str = "GET",
|
|
121
|
+
address: str | None = None,
|
|
122
|
+
data: dict[str, Any] | None = None,
|
|
123
|
+
) -> Any:
|
|
124
|
+
"""Utility function for HTTP requests for the API.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
method: HTTP method (GET, PUT, POST, DELETE)
|
|
128
|
+
address: API endpoint address
|
|
129
|
+
data: Optional data to send with the request
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The parsed JSON response from the API
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
PhueRequestTimeout: If the request times out
|
|
136
|
+
PhueException: If the request fails
|
|
137
|
+
"""
|
|
138
|
+
url = f"http://{self.ip}{address}"
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
142
|
+
if method == "GET" or method == "DELETE":
|
|
143
|
+
response = client.request(method, url)
|
|
144
|
+
elif method == "PUT" or method == "POST":
|
|
145
|
+
response = client.request(method, url, json=data)
|
|
146
|
+
else:
|
|
147
|
+
raise ValueError(f"Unsupported method: {method}")
|
|
148
|
+
|
|
149
|
+
logger.debug(f"{method} {address} {str(data)}")
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
return response.json()
|
|
152
|
+
|
|
153
|
+
except httpx.TimeoutException:
|
|
154
|
+
error = f"{method} Request to {url} timed out."
|
|
155
|
+
logger.exception(error)
|
|
156
|
+
raise PhueRequestTimeout(-1, error)
|
|
157
|
+
except httpx.HTTPStatusError as e:
|
|
158
|
+
error = f"{method} Request to {url} failed with status code {e.response.status_code}"
|
|
159
|
+
logger.exception(error)
|
|
160
|
+
raise PhueException(e.response.status_code, error)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
error = f"{method} Request to {url} failed: {str(e)}"
|
|
163
|
+
logger.exception(error)
|
|
164
|
+
raise PhueException(-1, error)
|
|
165
|
+
|
|
166
|
+
def get_ip_address(self, set_result: bool = False) -> str | None:
|
|
167
|
+
"""Get the bridge ip address from the meethue.com nupnp api.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
set_result: If True, set the IP address of this Bridge object
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The IP address if found, otherwise None
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
with httpx.Client() as client:
|
|
177
|
+
response = client.get("https://www.meethue.com/api/nupnp")
|
|
178
|
+
response.raise_for_status()
|
|
179
|
+
data = response.json()
|
|
180
|
+
|
|
181
|
+
logger.info("Connected to meethue.com/api/nupnp")
|
|
182
|
+
|
|
183
|
+
if not data:
|
|
184
|
+
logger.warning(
|
|
185
|
+
"No bridges found via meethue.com API. Your bridge may not be connected to the internet "
|
|
186
|
+
"or registered with the Hue cloud service. Try specifying the IP address manually."
|
|
187
|
+
)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
ip = str(data[0]["internalipaddress"])
|
|
191
|
+
|
|
192
|
+
if ip and set_result:
|
|
193
|
+
self.ip = ip
|
|
194
|
+
|
|
195
|
+
return ip
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.error(
|
|
198
|
+
f"Error getting IP address: {str(e)}. "
|
|
199
|
+
"Unable to discover bridge IP automatically. Consider the following steps:\n"
|
|
200
|
+
"1. Find your bridge IP in the Hue app (Settings > My Bridge)\n"
|
|
201
|
+
"2. Check your router's connected devices list\n"
|
|
202
|
+
"3. Use network scanning tools like 'nmap'\n"
|
|
203
|
+
"Then initialize the Bridge with explicit IP: phue.Bridge(ip='192.168.x.x')"
|
|
204
|
+
)
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def register_app(self) -> None:
|
|
208
|
+
"""Register this computer with the Hue bridge hardware and save the resulting access token.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
PhueRegistrationException: If the link button has not been pressed
|
|
212
|
+
PhueException: If registration fails for other reasons
|
|
213
|
+
"""
|
|
214
|
+
if self.ip is None:
|
|
215
|
+
raise PhueException(
|
|
216
|
+
-1,
|
|
217
|
+
"Cannot register without an IP address. Specify the bridge IP: "
|
|
218
|
+
"bridge = phue.Bridge(ip='192.168.x.x')",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
registration_request = {"devicetype": "python_hue_modern"}
|
|
222
|
+
try:
|
|
223
|
+
response = self.request("POST", "/api", registration_request)
|
|
224
|
+
except PhueException as e:
|
|
225
|
+
if "Failed to connect" in str(e) or "Connection refused" in str(e):
|
|
226
|
+
raise PhueException(
|
|
227
|
+
-1,
|
|
228
|
+
f"Failed to connect to bridge at {self.ip}. Verify the IP address is correct "
|
|
229
|
+
"and that the bridge is powered on and connected to your network.",
|
|
230
|
+
)
|
|
231
|
+
raise
|
|
232
|
+
|
|
233
|
+
for line in response:
|
|
234
|
+
for key in line:
|
|
235
|
+
if "success" in key:
|
|
236
|
+
with open(self.config_file_path, "w") as f:
|
|
237
|
+
logger.info(
|
|
238
|
+
"Writing configuration file to " + self.config_file_path
|
|
239
|
+
)
|
|
240
|
+
f.write(json.dumps({self.ip: line["success"]}))
|
|
241
|
+
logger.info("Reconnecting to the bridge")
|
|
242
|
+
self._username = line["success"]["username"]
|
|
243
|
+
return
|
|
244
|
+
if "error" in key:
|
|
245
|
+
error_type = line["error"]["type"]
|
|
246
|
+
if error_type == 101:
|
|
247
|
+
raise PhueRegistrationException(
|
|
248
|
+
error_type,
|
|
249
|
+
"The link button has not been pressed in the last 30 seconds. "
|
|
250
|
+
"Please press the button on the bridge and try again.",
|
|
251
|
+
)
|
|
252
|
+
if error_type == 7:
|
|
253
|
+
raise PhueException(error_type, "Unknown username")
|
|
254
|
+
|
|
255
|
+
def connect(self) -> None:
|
|
256
|
+
"""Connect to the Hue bridge.
|
|
257
|
+
|
|
258
|
+
Attempts to connect to the bridge using the provided IP and username,
|
|
259
|
+
or tries to load them from the config file. If that fails, it will
|
|
260
|
+
attempt to register with the bridge.
|
|
261
|
+
"""
|
|
262
|
+
logger.info("Attempting to connect to the bridge...")
|
|
263
|
+
# If the ip and username were provided at class init
|
|
264
|
+
if self.ip is not None and self._username is not None:
|
|
265
|
+
logger.info("Using ip: " + self.ip)
|
|
266
|
+
logger.info("Using username: " + self._username)
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
if self.ip is None or self._username is None:
|
|
270
|
+
try:
|
|
271
|
+
with open(self.config_file_path) as f:
|
|
272
|
+
config = json.loads(f.read())
|
|
273
|
+
if self.ip is None:
|
|
274
|
+
self.ip = list(config.keys())[0]
|
|
275
|
+
logger.info("Using ip from config: " + self.ip)
|
|
276
|
+
else:
|
|
277
|
+
logger.info("Using ip: " + self.ip)
|
|
278
|
+
if self._username is None:
|
|
279
|
+
self._username = config[self.ip]["username"]
|
|
280
|
+
logger.info("Using username from config: " + self._username)
|
|
281
|
+
else:
|
|
282
|
+
logger.info("Using username: " + self._username)
|
|
283
|
+
except FileNotFoundError:
|
|
284
|
+
logger.info("Config file not found, will attempt bridge registration")
|
|
285
|
+
if self.ip is None:
|
|
286
|
+
ip = self.get_ip_address(set_result=True)
|
|
287
|
+
if ip is None:
|
|
288
|
+
error_msg = (
|
|
289
|
+
"Could not find bridge IP address. Please specify it manually:\n"
|
|
290
|
+
"bridge = phue.Bridge(ip='192.168.x.x')"
|
|
291
|
+
)
|
|
292
|
+
logger.error(error_msg)
|
|
293
|
+
raise PhueException(-1, error_msg)
|
|
294
|
+
|
|
295
|
+
self.register_app()
|
|
296
|
+
except json.JSONDecodeError:
|
|
297
|
+
logger.error(
|
|
298
|
+
"Config file is not valid JSON, will attempt bridge registration"
|
|
299
|
+
)
|
|
300
|
+
self.register_app()
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.error(f"Error opening config file: {str(e)}")
|
|
303
|
+
if self.ip is None:
|
|
304
|
+
error_msg = (
|
|
305
|
+
"Could not load config and no IP address specified. "
|
|
306
|
+
"Please initialize with an IP address: phue.Bridge(ip='192.168.x.x')"
|
|
307
|
+
)
|
|
308
|
+
logger.error(error_msg)
|
|
309
|
+
raise PhueException(-1, error_msg)
|
|
310
|
+
self.register_app()
|
|
311
|
+
|
|
312
|
+
def get_light_id_by_name(self, name: str) -> int | None:
|
|
313
|
+
"""Lookup a light id based on string name. Case-sensitive.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
name: The name of the light to look up
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
The light ID if found, otherwise None
|
|
320
|
+
"""
|
|
321
|
+
lights = self.get_light()
|
|
322
|
+
for light_id in lights:
|
|
323
|
+
if name == lights[light_id]["name"]:
|
|
324
|
+
return int(light_id)
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
@overload
|
|
328
|
+
def get_light_objects(self) -> list[Light]: ...
|
|
329
|
+
@overload
|
|
330
|
+
def get_light_objects(self, mode: Literal["id"]) -> dict[int, Light]: ...
|
|
331
|
+
|
|
332
|
+
@overload
|
|
333
|
+
def get_light_objects(self, mode: Literal["name"]) -> dict[str, Light]: ...
|
|
334
|
+
|
|
335
|
+
@overload
|
|
336
|
+
def get_light_objects(self, mode: Literal["list"]) -> list[Light]: ...
|
|
337
|
+
|
|
338
|
+
def get_light_objects(
|
|
339
|
+
self, mode: str = "list"
|
|
340
|
+
) -> list[Light] | dict[int, Light] | dict[str, Light]:
|
|
341
|
+
"""Returns a collection containing the lights, either by name or id.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
mode: The return format - 'list' (default), 'id', or 'name'
|
|
345
|
+
'list': return a list of Light objects in order by ID
|
|
346
|
+
'id': return a dict of Light objects by light ID
|
|
347
|
+
'name': return a dict of Light objects by light name
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
A collection of Light objects in the requested format
|
|
351
|
+
"""
|
|
352
|
+
if not self.lights_by_id:
|
|
353
|
+
lights = self.request("GET", "/api/" + self.username + "/lights/")
|
|
354
|
+
for light in lights:
|
|
355
|
+
light_id = int(light)
|
|
356
|
+
self.lights_by_id[light_id] = Light(self, light_id)
|
|
357
|
+
self.lights_by_name[lights[light]["name"]] = self.lights_by_id[light_id]
|
|
358
|
+
|
|
359
|
+
if mode == "id":
|
|
360
|
+
return self.lights_by_id
|
|
361
|
+
if mode == "name":
|
|
362
|
+
return self.lights_by_name
|
|
363
|
+
if mode == "list":
|
|
364
|
+
# return lights in sorted id order, dicts have no natural order
|
|
365
|
+
return [self.lights_by_id[id] for id in sorted(self.lights_by_id)]
|
|
366
|
+
raise ValueError(f"Invalid mode: {mode}")
|
|
367
|
+
|
|
368
|
+
def get_sensor_id_by_name(self, name: str) -> int | None:
|
|
369
|
+
"""Lookup a sensor id based on string name. Case-sensitive.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
name: The name of the sensor to look up
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
The sensor ID if found, otherwise None
|
|
376
|
+
"""
|
|
377
|
+
sensors = self.get_sensor()
|
|
378
|
+
for sensor_id in sensors:
|
|
379
|
+
if name == sensors[sensor_id]["name"]:
|
|
380
|
+
return int(sensor_id)
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
@overload
|
|
384
|
+
def get_sensor_objects(self) -> list[Sensor]: ...
|
|
385
|
+
@overload
|
|
386
|
+
def get_sensor_objects(self, mode: Literal["list"]) -> list[Sensor]: ...
|
|
387
|
+
|
|
388
|
+
@overload
|
|
389
|
+
def get_sensor_objects(self, mode: Literal["id"]) -> dict[int, Sensor]: ...
|
|
390
|
+
|
|
391
|
+
@overload
|
|
392
|
+
def get_sensor_objects(self, mode: Literal["name"]) -> dict[str, Sensor]: ...
|
|
393
|
+
|
|
394
|
+
def get_sensor_objects(
|
|
395
|
+
self, mode: Literal["list", "id", "name"] = "list"
|
|
396
|
+
) -> list[Sensor] | dict[int, Sensor] | dict[str, Sensor]:
|
|
397
|
+
"""Returns a collection containing the sensors, either by name or id.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
mode: The return format - 'list' (default), 'id', or 'name'
|
|
401
|
+
'list': return a list of Sensor objects
|
|
402
|
+
'id': return a dict of Sensor objects by sensor ID
|
|
403
|
+
'name': return a dict of Sensor objects by sensor name
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
A collection of Sensor objects in the requested format
|
|
407
|
+
"""
|
|
408
|
+
if not self.sensors_by_id:
|
|
409
|
+
sensors = self.request("GET", "/api/" + self.username + "/sensors/")
|
|
410
|
+
for sensor in sensors:
|
|
411
|
+
sensor_id = int(sensor)
|
|
412
|
+
self.sensors_by_id[sensor_id] = Sensor(self, sensor_id)
|
|
413
|
+
self.sensors_by_name[sensors[sensor]["name"]] = self.sensors_by_id[
|
|
414
|
+
sensor_id
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
if mode == "id":
|
|
418
|
+
return self.sensors_by_id
|
|
419
|
+
if mode == "name":
|
|
420
|
+
return self.sensors_by_name
|
|
421
|
+
if mode == "list":
|
|
422
|
+
return list(self.sensors_by_id.values())
|
|
423
|
+
|
|
424
|
+
def __getitem__(self, key: int | str) -> Light:
|
|
425
|
+
"""Lights are accessible by indexing the bridge either with
|
|
426
|
+
an integer index or string name.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
key: Either a light ID (int) or a light name (str)
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
The Light object corresponding to the key
|
|
433
|
+
|
|
434
|
+
Raises:
|
|
435
|
+
KeyError: If the key is not a valid light ID or name
|
|
436
|
+
"""
|
|
437
|
+
if not self.lights_by_id:
|
|
438
|
+
self.get_light_objects("id")
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
if isinstance(key, int):
|
|
442
|
+
return self.lights_by_id[key]
|
|
443
|
+
else:
|
|
444
|
+
return self.lights_by_name[key]
|
|
445
|
+
except KeyError:
|
|
446
|
+
raise KeyError(
|
|
447
|
+
"Not a valid key (integer index starting with 1, or light name): "
|
|
448
|
+
+ str(key)
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def lights(self) -> list[Light]:
|
|
453
|
+
"""Access lights as a list"""
|
|
454
|
+
return self.get_light_objects("list")
|
|
455
|
+
|
|
456
|
+
def get_api(self) -> dict[str, Any]:
|
|
457
|
+
"""Returns the full api dictionary"""
|
|
458
|
+
return self.request("GET", "/api/" + self.username)
|
|
459
|
+
|
|
460
|
+
def get_light(
|
|
461
|
+
self, light_id: int | str | None = None, parameter: str | None = None
|
|
462
|
+
) -> Any:
|
|
463
|
+
"""Gets state by light_id and parameter.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
light_id: The ID of the light, or the name of the light, or None to get all lights
|
|
467
|
+
parameter: The parameter to get, or None to get all parameters
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
The requested parameter value, or a dict of all parameters if parameter is None
|
|
471
|
+
|
|
472
|
+
Raises:
|
|
473
|
+
KeyError: If the parameter is not valid for the light
|
|
474
|
+
"""
|
|
475
|
+
if isinstance(light_id, str) and not light_id.isdigit():
|
|
476
|
+
light_id = self.get_light_id_by_name(light_id)
|
|
477
|
+
|
|
478
|
+
if light_id is None:
|
|
479
|
+
return self.request("GET", "/api/" + self.username + "/lights/")
|
|
480
|
+
|
|
481
|
+
state = self.request(
|
|
482
|
+
"GET", "/api/" + self.username + "/lights/" + str(light_id)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if parameter is None:
|
|
486
|
+
return state
|
|
487
|
+
if parameter in ["name", "type", "uniqueid", "swversion"]:
|
|
488
|
+
return state[parameter]
|
|
489
|
+
else:
|
|
490
|
+
try:
|
|
491
|
+
return state["state"][parameter]
|
|
492
|
+
except KeyError:
|
|
493
|
+
raise KeyError(
|
|
494
|
+
f"Not a valid key, parameter {parameter} is not associated with light {light_id}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def set_light(
|
|
498
|
+
self,
|
|
499
|
+
light_id: int | str | list[int] | list[str],
|
|
500
|
+
parameter: str | dict[str, Any],
|
|
501
|
+
value: Any = None,
|
|
502
|
+
transitiontime: int | None = None,
|
|
503
|
+
) -> list[dict[Hashable, Any]]:
|
|
504
|
+
"""Adjust properties of one or more lights.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
light_id: A single light ID/name or a list of light IDs/names
|
|
508
|
+
parameter: Either a parameter name or a dict of parameters to set
|
|
509
|
+
value: The value to set if parameter is a string
|
|
510
|
+
transitiontime: Time for this transition to take place (in deciseconds)
|
|
511
|
+
Note that transitiontime only applies to this light command,
|
|
512
|
+
it is not saved as a setting for future use.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
A list of responses from the API
|
|
516
|
+
"""
|
|
517
|
+
if isinstance(parameter, dict):
|
|
518
|
+
data = parameter
|
|
519
|
+
else:
|
|
520
|
+
data = {parameter: value}
|
|
521
|
+
|
|
522
|
+
if transitiontime is not None:
|
|
523
|
+
data["transitiontime"] = int(
|
|
524
|
+
round(transitiontime)
|
|
525
|
+
) # must be int for request format
|
|
526
|
+
|
|
527
|
+
light_id_array: list[Any] = []
|
|
528
|
+
if isinstance(light_id, int | str):
|
|
529
|
+
light_id_array = [light_id]
|
|
530
|
+
else:
|
|
531
|
+
light_id_array = light_id
|
|
532
|
+
|
|
533
|
+
result: list[dict[Hashable, Any]] = []
|
|
534
|
+
for light in light_id_array:
|
|
535
|
+
logger.debug(str(data))
|
|
536
|
+
if parameter == "name":
|
|
537
|
+
result.append(
|
|
538
|
+
self.request(
|
|
539
|
+
"PUT",
|
|
540
|
+
"/api/" + self.username + "/lights/" + str(light),
|
|
541
|
+
data,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
else:
|
|
545
|
+
if isinstance(light, str) and not light.isdigit():
|
|
546
|
+
converted_light = self.get_light_id_by_name(light)
|
|
547
|
+
if converted_light is None:
|
|
548
|
+
logger.warning(f"Could not find light with name: {light}")
|
|
549
|
+
continue
|
|
550
|
+
else:
|
|
551
|
+
converted_light = light
|
|
552
|
+
result.append(
|
|
553
|
+
self.request(
|
|
554
|
+
"PUT",
|
|
555
|
+
"/api/"
|
|
556
|
+
+ self.username
|
|
557
|
+
+ "/lights/"
|
|
558
|
+
+ str(converted_light)
|
|
559
|
+
+ "/state",
|
|
560
|
+
data,
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
if (
|
|
564
|
+
result
|
|
565
|
+
and isinstance(result[-1], list)
|
|
566
|
+
and result[-1]
|
|
567
|
+
and "error" in result[-1][0]
|
|
568
|
+
):
|
|
569
|
+
logger.warning(
|
|
570
|
+
f"ERROR: {result[-1][0]['error']['description']} for light {light}"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
logger.debug(result)
|
|
574
|
+
return result
|
|
575
|
+
|
|
576
|
+
# Sensors #####
|
|
577
|
+
@property
|
|
578
|
+
def sensors(self) -> list[Sensor]:
|
|
579
|
+
"""Access sensors as a list"""
|
|
580
|
+
return self.get_sensor_objects()
|
|
581
|
+
|
|
582
|
+
def create_sensor(
|
|
583
|
+
self,
|
|
584
|
+
name: str,
|
|
585
|
+
modelid: str,
|
|
586
|
+
swversion: str,
|
|
587
|
+
sensor_type: str,
|
|
588
|
+
uniqueid: str,
|
|
589
|
+
manufacturername: str,
|
|
590
|
+
state: dict[str, Any] = {},
|
|
591
|
+
config: dict[str, Any] = {},
|
|
592
|
+
recycle: bool = False,
|
|
593
|
+
) -> tuple[int | None, dict[str, Any] | None]:
|
|
594
|
+
"""Create a new sensor in the bridge.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
name: Name for the sensor
|
|
598
|
+
modelid: Model ID of the sensor
|
|
599
|
+
swversion: Software version of the sensor
|
|
600
|
+
sensor_type: Type of the sensor
|
|
601
|
+
uniqueid: Unique ID of the sensor
|
|
602
|
+
manufacturername: Manufacturer name
|
|
603
|
+
state: Optional state dictionary
|
|
604
|
+
config: Optional config dictionary
|
|
605
|
+
recycle: Whether the sensor should be automatically removed when last reference is gone
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
A tuple of (new_id, None) if created successfully, or (None, error) if failed
|
|
609
|
+
"""
|
|
610
|
+
data: dict[str, Any] = {
|
|
611
|
+
"name": name,
|
|
612
|
+
"modelid": modelid,
|
|
613
|
+
"swversion": swversion,
|
|
614
|
+
"type": sensor_type,
|
|
615
|
+
"uniqueid": uniqueid,
|
|
616
|
+
"manufacturername": manufacturername,
|
|
617
|
+
"recycle": recycle,
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if state:
|
|
621
|
+
data["state"] = state
|
|
622
|
+
|
|
623
|
+
if config:
|
|
624
|
+
data["config"] = config
|
|
625
|
+
|
|
626
|
+
result = self.request("POST", "/api/" + self.username + "/sensors/", data)
|
|
627
|
+
|
|
628
|
+
if "success" in result[0]:
|
|
629
|
+
new_id = int(result[0]["success"]["id"])
|
|
630
|
+
logger.debug(f"Created sensor with ID {new_id}")
|
|
631
|
+
new_sensor = Sensor(self, new_id)
|
|
632
|
+
self.sensors_by_id[new_id] = new_sensor
|
|
633
|
+
self.sensors_by_name[name] = new_sensor
|
|
634
|
+
return new_id, None
|
|
635
|
+
else:
|
|
636
|
+
logger.debug(f"Failed to create sensor: {result[0]}")
|
|
637
|
+
return None, result[0]
|
|
638
|
+
|
|
639
|
+
def get_sensor(
|
|
640
|
+
self, sensor_id: int | str | None = None, parameter: str | None = None
|
|
641
|
+
) -> Any:
|
|
642
|
+
"""Gets state by sensor_id and parameter.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
sensor_id: The ID of the sensor, or the name of the sensor, or None to get all sensors
|
|
646
|
+
parameter: The parameter to get, or None to get all parameters
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
The requested parameter value, or a dict of all parameters if parameter is None,
|
|
650
|
+
or None if the sensor could not be found
|
|
651
|
+
"""
|
|
652
|
+
if isinstance(sensor_id, str) and not sensor_id.isdigit():
|
|
653
|
+
sensor_id = self.get_sensor_id_by_name(sensor_id)
|
|
654
|
+
|
|
655
|
+
if sensor_id is None:
|
|
656
|
+
return self.request("GET", "/api/" + self.username + "/sensors/")
|
|
657
|
+
|
|
658
|
+
data = self.request(
|
|
659
|
+
"GET", "/api/" + self.username + "/sensors/" + str(sensor_id)
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
if isinstance(data, list):
|
|
663
|
+
logger.debug(f"Unable to read sensor with ID {sensor_id}: {data}")
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
if parameter is None:
|
|
667
|
+
return data
|
|
668
|
+
return data[parameter]
|
|
669
|
+
|
|
670
|
+
def set_sensor(
|
|
671
|
+
self, sensor_id: int, parameter: str | dict[str, Any], value: Any = None
|
|
672
|
+
) -> Any:
|
|
673
|
+
"""Adjust properties of a sensor.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
sensor_id: The ID of the sensor
|
|
677
|
+
parameter: Either a parameter name or a dict of parameters to set
|
|
678
|
+
value: The value to set if parameter is a string
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
The response from the API
|
|
682
|
+
"""
|
|
683
|
+
if isinstance(parameter, dict):
|
|
684
|
+
data = parameter
|
|
685
|
+
else:
|
|
686
|
+
data = {parameter: value}
|
|
687
|
+
|
|
688
|
+
logger.debug(str(data))
|
|
689
|
+
result = self.request(
|
|
690
|
+
"PUT", "/api/" + self.username + "/sensors/" + str(sensor_id), data
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
if isinstance(result, list) and result and "error" in result[0]:
|
|
694
|
+
logger.warning(
|
|
695
|
+
f"ERROR: {result[0]['error']['description']} for sensor {sensor_id}"
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
if TYPE_CHECKING:
|
|
699
|
+
result = cast(list[dict[str, Any]], result)
|
|
700
|
+
|
|
701
|
+
logger.debug(result)
|
|
702
|
+
return result
|
|
703
|
+
|
|
704
|
+
def set_sensor_state(
|
|
705
|
+
self, sensor_id: int, parameter: str | dict[str, Any], value: Any = None
|
|
706
|
+
) -> Any:
|
|
707
|
+
"""Adjust the "state" object of a sensor.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
sensor_id: The ID of the sensor
|
|
711
|
+
parameter: Either a parameter name or a dict of parameters to set
|
|
712
|
+
value: The value to set if parameter is a string
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
The response from the API
|
|
716
|
+
"""
|
|
717
|
+
return self.set_sensor_content(sensor_id, parameter, value, "state")
|
|
718
|
+
|
|
719
|
+
def set_sensor_config(
|
|
720
|
+
self, sensor_id: int, parameter: str | dict[str, Any], value: Any = None
|
|
721
|
+
) -> Any:
|
|
722
|
+
"""Adjust the "config" object of a sensor.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
sensor_id: The ID of the sensor
|
|
726
|
+
parameter: Either a parameter name or a dict of parameters to set
|
|
727
|
+
value: The value to set if parameter is a string
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
The response from the API
|
|
731
|
+
"""
|
|
732
|
+
return self.set_sensor_content(sensor_id, parameter, value, "config")
|
|
733
|
+
|
|
734
|
+
def set_sensor_content(
|
|
735
|
+
self,
|
|
736
|
+
sensor_id: int,
|
|
737
|
+
parameter: str | dict[str, Any],
|
|
738
|
+
value: Any = None,
|
|
739
|
+
structure: str = "state",
|
|
740
|
+
) -> Any:
|
|
741
|
+
"""Adjust the "state" or "config" structures of a sensor.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
sensor_id: The ID of the sensor
|
|
745
|
+
parameter: Either a parameter name or a dict of parameters to set
|
|
746
|
+
value: The value to set if parameter is a string
|
|
747
|
+
structure: Either "state" or "config"
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
The response from the API, or False if the structure is invalid
|
|
751
|
+
"""
|
|
752
|
+
if structure != "state" and structure != "config":
|
|
753
|
+
logger.debug("set_sensor_content expects structure 'state' or 'config'.")
|
|
754
|
+
return False
|
|
755
|
+
|
|
756
|
+
if isinstance(parameter, dict):
|
|
757
|
+
data = parameter.copy()
|
|
758
|
+
else:
|
|
759
|
+
data = {parameter: value}
|
|
760
|
+
|
|
761
|
+
# Attempting to set this causes an error.
|
|
762
|
+
if "lastupdated" in data:
|
|
763
|
+
del data["lastupdated"]
|
|
764
|
+
|
|
765
|
+
logger.debug(str(data))
|
|
766
|
+
result = self.request(
|
|
767
|
+
"PUT",
|
|
768
|
+
"/api/" + self.username + "/sensors/" + str(sensor_id) + "/" + structure,
|
|
769
|
+
data,
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
if isinstance(result, list) and result and "error" in result[0]:
|
|
773
|
+
logger.warning(
|
|
774
|
+
f"ERROR: {result[0]['error']['description']} for sensor {sensor_id}"
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
if TYPE_CHECKING:
|
|
778
|
+
result = cast(list[dict[str, Any]], result)
|
|
779
|
+
|
|
780
|
+
logger.debug(result)
|
|
781
|
+
return result
|
|
782
|
+
|
|
783
|
+
def delete_sensor(self, sensor_id: int) -> Any:
|
|
784
|
+
"""Delete a sensor from the bridge.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
sensor_id: The ID of the sensor to delete
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
The response from the API, or None if the sensor doesn't exist
|
|
791
|
+
"""
|
|
792
|
+
try:
|
|
793
|
+
name = self.sensors_by_id[sensor_id].name
|
|
794
|
+
del self.sensors_by_name[name]
|
|
795
|
+
del self.sensors_by_id[sensor_id]
|
|
796
|
+
return self.request(
|
|
797
|
+
"DELETE", "/api/" + self.username + "/sensors/" + str(sensor_id)
|
|
798
|
+
)
|
|
799
|
+
except KeyError:
|
|
800
|
+
logger.debug(f"Unable to delete nonexistent sensor with ID {sensor_id}")
|
|
801
|
+
return None
|
|
802
|
+
|
|
803
|
+
# Groups of lights #####
|
|
804
|
+
@property
|
|
805
|
+
def groups(self) -> list[Group]:
|
|
806
|
+
"""Access groups as a list"""
|
|
807
|
+
return [Group(self, int(groupid)) for groupid in self.get_group().keys()]
|
|
808
|
+
|
|
809
|
+
def get_group_id_by_name(self, name: str) -> int | None:
|
|
810
|
+
"""Lookup a group id based on string name. Case-sensitive.
|
|
811
|
+
|
|
812
|
+
Args:
|
|
813
|
+
name: The name of the group to look up
|
|
814
|
+
|
|
815
|
+
Returns:
|
|
816
|
+
The group ID if found, otherwise None
|
|
817
|
+
"""
|
|
818
|
+
groups = self.get_group()
|
|
819
|
+
for group_id in groups:
|
|
820
|
+
if name == groups[group_id]["name"]:
|
|
821
|
+
return int(group_id)
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
def get_group(
|
|
825
|
+
self, group_id: int | str | None = None, parameter: str | None = None
|
|
826
|
+
) -> Any:
|
|
827
|
+
"""Gets state by group_id and parameter.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
group_id: The ID of the group, or the name of the group, or None to get all groups
|
|
831
|
+
parameter: The parameter to get, or None to get all parameters
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
The requested parameter value, or a dict of all parameters if parameter is None,
|
|
835
|
+
or None if the group could not be found
|
|
836
|
+
"""
|
|
837
|
+
if isinstance(group_id, str) and not group_id.isdigit():
|
|
838
|
+
group_id = self.get_group_id_by_name(group_id)
|
|
839
|
+
if group_id is None:
|
|
840
|
+
logger.error("Group name does not exist")
|
|
841
|
+
return None
|
|
842
|
+
|
|
843
|
+
if group_id is None:
|
|
844
|
+
return self.request("GET", "/api/" + self.username + "/groups/")
|
|
845
|
+
|
|
846
|
+
if parameter is None:
|
|
847
|
+
return self.request(
|
|
848
|
+
"GET", "/api/" + self.username + "/groups/" + str(group_id)
|
|
849
|
+
)
|
|
850
|
+
elif parameter in ("name", "lights"):
|
|
851
|
+
return self.request(
|
|
852
|
+
"GET", "/api/" + self.username + "/groups/" + str(group_id)
|
|
853
|
+
)[parameter]
|
|
854
|
+
elif parameter in ("any_on", "all_on"):
|
|
855
|
+
return self.request(
|
|
856
|
+
"GET", "/api/" + self.username + "/groups/" + str(group_id)
|
|
857
|
+
)["state"][parameter]
|
|
858
|
+
else:
|
|
859
|
+
return self.request(
|
|
860
|
+
"GET", "/api/" + self.username + "/groups/" + str(group_id)
|
|
861
|
+
)["action"][parameter]
|
|
862
|
+
|
|
863
|
+
def set_group(
|
|
864
|
+
self,
|
|
865
|
+
group_id: int | str | list[int | str],
|
|
866
|
+
parameter: str | dict[str, Any],
|
|
867
|
+
value: Any = None,
|
|
868
|
+
transitiontime: int | None = None,
|
|
869
|
+
) -> list[Any]:
|
|
870
|
+
"""Change light settings for a group.
|
|
871
|
+
|
|
872
|
+
Args:
|
|
873
|
+
group_id: A single group ID/name or a list of group IDs/names
|
|
874
|
+
parameter: Either a parameter name or a dict of parameters to set
|
|
875
|
+
value: The value to set if parameter is a string
|
|
876
|
+
transitiontime: Time for this transition to take place (in deciseconds)
|
|
877
|
+
|
|
878
|
+
Returns:
|
|
879
|
+
A list of responses from the API
|
|
880
|
+
"""
|
|
881
|
+
data: dict[str, Any] = {}
|
|
882
|
+
username = self.username
|
|
883
|
+
|
|
884
|
+
if isinstance(parameter, dict):
|
|
885
|
+
data = parameter
|
|
886
|
+
elif parameter == "lights" and (
|
|
887
|
+
isinstance(value, list) or isinstance(value, int)
|
|
888
|
+
):
|
|
889
|
+
if isinstance(value, int):
|
|
890
|
+
value = [value]
|
|
891
|
+
|
|
892
|
+
if TYPE_CHECKING:
|
|
893
|
+
value = cast(Iterable[int | str], value)
|
|
894
|
+
|
|
895
|
+
data = {parameter: [str(x) for x in value]}
|
|
896
|
+
else:
|
|
897
|
+
data = {parameter: value}
|
|
898
|
+
|
|
899
|
+
if transitiontime is not None:
|
|
900
|
+
data["transitiontime"] = int(
|
|
901
|
+
round(transitiontime)
|
|
902
|
+
) # must be int for request format
|
|
903
|
+
|
|
904
|
+
group_id_array: list[int | str] = []
|
|
905
|
+
if isinstance(group_id, int | str):
|
|
906
|
+
group_id_array = [group_id]
|
|
907
|
+
else:
|
|
908
|
+
group_id_array = group_id
|
|
909
|
+
|
|
910
|
+
result: list[Any] = []
|
|
911
|
+
for group in group_id_array:
|
|
912
|
+
logger.debug(str(data))
|
|
913
|
+
if isinstance(group, str) and not group.isdigit():
|
|
914
|
+
converted_group = self.get_group_id_by_name(group)
|
|
915
|
+
if converted_group is None:
|
|
916
|
+
logger.error("Group name does not exist")
|
|
917
|
+
continue
|
|
918
|
+
else:
|
|
919
|
+
converted_group = group
|
|
920
|
+
|
|
921
|
+
if parameter in ("name", "lights"):
|
|
922
|
+
result.append(
|
|
923
|
+
self.request(
|
|
924
|
+
"PUT",
|
|
925
|
+
"/api/" + username + "/groups/" + str(converted_group),
|
|
926
|
+
data,
|
|
927
|
+
)
|
|
928
|
+
)
|
|
929
|
+
else:
|
|
930
|
+
result.append(
|
|
931
|
+
self.request(
|
|
932
|
+
"PUT",
|
|
933
|
+
"/api/"
|
|
934
|
+
+ username
|
|
935
|
+
+ "/groups/"
|
|
936
|
+
+ str(converted_group)
|
|
937
|
+
+ "/action",
|
|
938
|
+
data,
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
if (
|
|
943
|
+
result
|
|
944
|
+
and isinstance(result[-1], list)
|
|
945
|
+
and result[-1]
|
|
946
|
+
and "error" in result[-1][0]
|
|
947
|
+
):
|
|
948
|
+
logger.warning(
|
|
949
|
+
f"ERROR: {result[-1][0]['error']['description']} for group {group}"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
logger.debug(result)
|
|
953
|
+
return result
|
|
954
|
+
|
|
955
|
+
def create_group(self, name: str, lights: list[int] | None = None) -> Any:
|
|
956
|
+
"""Create a group of lights
|
|
957
|
+
|
|
958
|
+
Args:
|
|
959
|
+
name: Name for this group of lights
|
|
960
|
+
lights: List of lights to be in the group
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
The response from the API
|
|
964
|
+
"""
|
|
965
|
+
if lights is None:
|
|
966
|
+
lights = []
|
|
967
|
+
|
|
968
|
+
data = {"lights": [str(x) for x in lights], "name": name}
|
|
969
|
+
return self.request("POST", "/api/" + self.username + "/groups/", data)
|
|
970
|
+
|
|
971
|
+
def delete_group(self, group_id: int) -> Any:
|
|
972
|
+
"""Delete a group from the bridge.
|
|
973
|
+
|
|
974
|
+
Args:
|
|
975
|
+
group_id: The ID of the group to delete
|
|
976
|
+
|
|
977
|
+
Returns:
|
|
978
|
+
The response from the API
|
|
979
|
+
"""
|
|
980
|
+
return self.request(
|
|
981
|
+
"DELETE", "/api/" + self.username + "/groups/" + str(group_id)
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
# Scenes #####
|
|
985
|
+
@property
|
|
986
|
+
def scenes(self) -> list[Scene]:
|
|
987
|
+
"""Access scenes as a list"""
|
|
988
|
+
return [Scene(k, **v) for k, v in self.get_scene().items()]
|
|
989
|
+
|
|
990
|
+
def create_group_scene(self, name: str, group: str) -> Any:
|
|
991
|
+
"""Create a Group Scene
|
|
992
|
+
|
|
993
|
+
Group scenes are based on the definition of groups and contain always all
|
|
994
|
+
lights from the selected group. No other lights from other rooms can be
|
|
995
|
+
added to a group scene and the group scene can not contain less lights
|
|
996
|
+
as available in the selected group. If a group is extended with new lights,
|
|
997
|
+
the new lights are added with default color to all group scenes based on
|
|
998
|
+
the corresponding group. This app has no influence on this behavior, it
|
|
999
|
+
was defined by Philips.
|
|
1000
|
+
|
|
1001
|
+
Args:
|
|
1002
|
+
name: The name of the scene to be created
|
|
1003
|
+
group: The group id of where the scene will be added
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
The response from the API
|
|
1007
|
+
"""
|
|
1008
|
+
data = {"name": name, "group": group, "recycle": True, "type": "GroupScene"}
|
|
1009
|
+
return self.request("POST", "/api/" + self.username + "/scenes", data)
|
|
1010
|
+
|
|
1011
|
+
def modify_scene(self, scene_id: str, data: dict[str, Any]) -> Any:
|
|
1012
|
+
"""Modify a scene with the given data.
|
|
1013
|
+
|
|
1014
|
+
Args:
|
|
1015
|
+
scene_id: The ID of the scene to modify
|
|
1016
|
+
data: Data to update in the scene
|
|
1017
|
+
|
|
1018
|
+
Returns:
|
|
1019
|
+
The response from the API
|
|
1020
|
+
"""
|
|
1021
|
+
return self.request(
|
|
1022
|
+
"PUT", "/api/" + self.username + "/scenes/" + scene_id, data
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
def get_scene(self) -> dict[str, Any]:
|
|
1026
|
+
"""Get all scenes from the bridge.
|
|
1027
|
+
|
|
1028
|
+
Returns:
|
|
1029
|
+
A dictionary of scenes
|
|
1030
|
+
"""
|
|
1031
|
+
return self.request("GET", "/api/" + self.username + "/scenes")
|
|
1032
|
+
|
|
1033
|
+
def activate_scene(
|
|
1034
|
+
self, group_id: int, scene_id: str, transition_time: int = 4
|
|
1035
|
+
) -> Any:
|
|
1036
|
+
"""Activate a scene for a given group.
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
group_id: The ID of the group
|
|
1040
|
+
scene_id: The ID of the scene to activate
|
|
1041
|
+
transition_time: Time for the transition to take place (in deciseconds)
|
|
1042
|
+
|
|
1043
|
+
Returns:
|
|
1044
|
+
The response from the API
|
|
1045
|
+
"""
|
|
1046
|
+
return self.request(
|
|
1047
|
+
"PUT",
|
|
1048
|
+
"/api/" + self.username + "/groups/" + str(group_id) + "/action",
|
|
1049
|
+
{"scene": scene_id, "transitiontime": transition_time},
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
def run_scene(
|
|
1053
|
+
self, group_name: str, scene_name: str, transition_time: int = 4
|
|
1054
|
+
) -> bool:
|
|
1055
|
+
"""Run a scene by group and scene name.
|
|
1056
|
+
|
|
1057
|
+
As of 1.11 of the Hue API the scenes are accessible in the
|
|
1058
|
+
API. With the gen 2 of the official HUE app everything is
|
|
1059
|
+
organized by room groups.
|
|
1060
|
+
|
|
1061
|
+
This provides a convenience way of activating scenes by group
|
|
1062
|
+
name and scene name. If we find exactly 1 group and 1 scene
|
|
1063
|
+
with the matching names, we run them.
|
|
1064
|
+
|
|
1065
|
+
If we find more than one we run the first scene who has
|
|
1066
|
+
exactly the same lights defined as the group. This is far from
|
|
1067
|
+
perfect, but is convenient for setting lights symbolically (and
|
|
1068
|
+
can be improved later).
|
|
1069
|
+
|
|
1070
|
+
Args:
|
|
1071
|
+
group_name: The name of the group
|
|
1072
|
+
scene_name: The name of the scene
|
|
1073
|
+
transition_time: The duration of the transition in deciseconds
|
|
1074
|
+
|
|
1075
|
+
Returns:
|
|
1076
|
+
True if a scene was run, False otherwise
|
|
1077
|
+
"""
|
|
1078
|
+
groups = [x for x in self.groups if x.name == group_name]
|
|
1079
|
+
scenes = [x for x in self.scenes if x.name == scene_name]
|
|
1080
|
+
|
|
1081
|
+
if len(groups) != 1:
|
|
1082
|
+
logger.warning(f"run_scene: More than 1 group found by name {group_name}")
|
|
1083
|
+
return False
|
|
1084
|
+
|
|
1085
|
+
group = groups[0]
|
|
1086
|
+
|
|
1087
|
+
if len(scenes) == 0:
|
|
1088
|
+
logger.warning(f"run_scene: No scene found {scene_name}")
|
|
1089
|
+
return False
|
|
1090
|
+
|
|
1091
|
+
if len(scenes) == 1:
|
|
1092
|
+
self.activate_scene(group.group_id, scenes[0].scene_id, transition_time)
|
|
1093
|
+
return True
|
|
1094
|
+
|
|
1095
|
+
# otherwise, lets figure out if one of the named scenes uses
|
|
1096
|
+
# all the lights of the group
|
|
1097
|
+
group_lights = sorted([x.light_id for x in group.lights])
|
|
1098
|
+
for scene in scenes:
|
|
1099
|
+
if group_lights == scene.lights:
|
|
1100
|
+
self.activate_scene(group.group_id, scene.scene_id, transition_time)
|
|
1101
|
+
return True
|
|
1102
|
+
|
|
1103
|
+
logger.warning(
|
|
1104
|
+
f"run_scene: did not find a scene: {scene_name} "
|
|
1105
|
+
f"that shared lights with group {group_name}"
|
|
1106
|
+
)
|
|
1107
|
+
return False
|
|
1108
|
+
|
|
1109
|
+
def delete_scene(self, scene_id: str) -> Any:
|
|
1110
|
+
"""Delete a scene from the bridge.
|
|
1111
|
+
|
|
1112
|
+
Args:
|
|
1113
|
+
scene_id: The ID of the scene to delete
|
|
1114
|
+
|
|
1115
|
+
Returns:
|
|
1116
|
+
The response from the API, or None if there was an error
|
|
1117
|
+
"""
|
|
1118
|
+
try:
|
|
1119
|
+
return self.request(
|
|
1120
|
+
"DELETE", "/api/" + self.username + "/scenes/" + str(scene_id)
|
|
1121
|
+
)
|
|
1122
|
+
except Exception as e:
|
|
1123
|
+
logger.debug(f"Unable to delete scene with ID {scene_id}: {str(e)}")
|
|
1124
|
+
return None
|
|
1125
|
+
|
|
1126
|
+
# Schedules #####
|
|
1127
|
+
def get_schedule(
|
|
1128
|
+
self, schedule_id: int | None = None, parameter: str | None = None
|
|
1129
|
+
) -> Any:
|
|
1130
|
+
"""Get schedules from the bridge.
|
|
1131
|
+
|
|
1132
|
+
Args:
|
|
1133
|
+
schedule_id: The ID of the schedule, or None to get all schedules
|
|
1134
|
+
parameter: The parameter to get, or None to get all parameters
|
|
1135
|
+
|
|
1136
|
+
Returns:
|
|
1137
|
+
The requested parameter value, or a dict of all parameters if parameter is None
|
|
1138
|
+
"""
|
|
1139
|
+
if schedule_id is None:
|
|
1140
|
+
return self.request("GET", "/api/" + self.username + "/schedules")
|
|
1141
|
+
if parameter is None:
|
|
1142
|
+
return self.request(
|
|
1143
|
+
"GET", "/api/" + self.username + "/schedules/" + str(schedule_id)
|
|
1144
|
+
)
|
|
1145
|
+
return self.request(
|
|
1146
|
+
"GET", "/api/" + self.username + "/schedules/" + str(schedule_id)
|
|
1147
|
+
)[parameter]
|
|
1148
|
+
|
|
1149
|
+
def create_schedule(
|
|
1150
|
+
self,
|
|
1151
|
+
name: str,
|
|
1152
|
+
time: str,
|
|
1153
|
+
light_id: int,
|
|
1154
|
+
data: dict[str, Any],
|
|
1155
|
+
description: str = " ",
|
|
1156
|
+
) -> Any:
|
|
1157
|
+
"""Create a schedule to control lights at a specific time.
|
|
1158
|
+
|
|
1159
|
+
Args:
|
|
1160
|
+
name: Name for the schedule
|
|
1161
|
+
time: Time for the schedule in the format specified by the Hue API
|
|
1162
|
+
light_id: The ID of the light to control
|
|
1163
|
+
data: Data containing the light state to set
|
|
1164
|
+
description: Optional description for the schedule
|
|
1165
|
+
|
|
1166
|
+
Returns:
|
|
1167
|
+
The response from the API
|
|
1168
|
+
"""
|
|
1169
|
+
schedule: dict[str, Any] = {
|
|
1170
|
+
"name": name,
|
|
1171
|
+
"localtime": time,
|
|
1172
|
+
"description": description,
|
|
1173
|
+
"command": {
|
|
1174
|
+
"method": "PUT",
|
|
1175
|
+
"address": (
|
|
1176
|
+
"/api/" + self.username + "/lights/" + str(light_id) + "/state"
|
|
1177
|
+
),
|
|
1178
|
+
"body": data,
|
|
1179
|
+
},
|
|
1180
|
+
}
|
|
1181
|
+
return self.request("POST", "/api/" + self.username + "/schedules", schedule)
|
|
1182
|
+
|
|
1183
|
+
def set_schedule_attributes(
|
|
1184
|
+
self, schedule_id: int, attributes: dict[str, Any]
|
|
1185
|
+
) -> Any:
|
|
1186
|
+
"""Update schedule attributes.
|
|
1187
|
+
|
|
1188
|
+
Args:
|
|
1189
|
+
schedule_id: The ID of the schedule
|
|
1190
|
+
attributes: Dictionary with attributes and their new values
|
|
1191
|
+
|
|
1192
|
+
Returns:
|
|
1193
|
+
The response from the API
|
|
1194
|
+
"""
|
|
1195
|
+
return self.request(
|
|
1196
|
+
"PUT",
|
|
1197
|
+
"/api/" + self.username + "/schedules/" + str(schedule_id),
|
|
1198
|
+
data=attributes,
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
def create_group_schedule(
|
|
1202
|
+
self,
|
|
1203
|
+
name: str,
|
|
1204
|
+
time: str,
|
|
1205
|
+
group_id: int,
|
|
1206
|
+
data: dict[str, Any],
|
|
1207
|
+
description: str = " ",
|
|
1208
|
+
) -> Any:
|
|
1209
|
+
"""Create a schedule to control a group at a specific time.
|
|
1210
|
+
|
|
1211
|
+
Args:
|
|
1212
|
+
name: Name for the schedule
|
|
1213
|
+
time: Time for the schedule in the format specified by the Hue API
|
|
1214
|
+
group_id: The ID of the group to control
|
|
1215
|
+
data: Data containing the group state to set
|
|
1216
|
+
description: Optional description for the schedule
|
|
1217
|
+
|
|
1218
|
+
Returns:
|
|
1219
|
+
The response from the API
|
|
1220
|
+
"""
|
|
1221
|
+
schedule = {
|
|
1222
|
+
"name": name,
|
|
1223
|
+
"localtime": time,
|
|
1224
|
+
"description": description,
|
|
1225
|
+
"command": {
|
|
1226
|
+
"method": "PUT",
|
|
1227
|
+
"address": (
|
|
1228
|
+
"/api/" + self.username + "/groups/" + str(group_id) + "/action"
|
|
1229
|
+
),
|
|
1230
|
+
"body": data,
|
|
1231
|
+
},
|
|
1232
|
+
}
|
|
1233
|
+
return self.request("POST", "/api/" + self.username + "/schedules", schedule)
|
|
1234
|
+
|
|
1235
|
+
def delete_schedule(self, schedule_id: int) -> Any:
|
|
1236
|
+
"""Delete a schedule from the bridge.
|
|
1237
|
+
|
|
1238
|
+
Args:
|
|
1239
|
+
schedule_id: The ID of the schedule to delete
|
|
1240
|
+
|
|
1241
|
+
Returns:
|
|
1242
|
+
The response from the API
|
|
1243
|
+
"""
|
|
1244
|
+
return self.request(
|
|
1245
|
+
"DELETE", "/api/" + self.username + "/schedules/" + str(schedule_id)
|
|
1246
|
+
)
|