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/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
+ )