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 ADDED
@@ -0,0 +1,37 @@
1
+ """
2
+ phue2 - A modernized Philips Hue Python library
3
+ Based on the original phue by Nathanaël Lécaudé
4
+ Original protocol hacking by rsmck: http://rsmck.co.uk/hue
5
+
6
+ Published under the MIT license
7
+
8
+ "Hue Personal Wireless Lighting" is a trademark owned by Koninklijke Philips Electronics N.V.
9
+ """
10
+
11
+ from .exceptions import PhueException, PhueRegistrationException, PhueRequestTimeout
12
+ from .light import Light
13
+ from .sensor import Sensor, SensorState, SensorConfig
14
+ from .group import Group, AllLights
15
+ from .scene import Scene
16
+ from .bridge import Bridge
17
+ from ._internal.console import console
18
+
19
+ import logging
20
+
21
+ logger = logging.getLogger("phue2")
22
+
23
+
24
+ __all__ = [
25
+ "Bridge",
26
+ "PhueException",
27
+ "PhueRegistrationException",
28
+ "PhueRequestTimeout",
29
+ "Light",
30
+ "Group",
31
+ "AllLights",
32
+ "Scene",
33
+ "Sensor",
34
+ "SensorState",
35
+ "SensorConfig",
36
+ "console",
37
+ ]
phue2/__main__.py ADDED
@@ -0,0 +1,408 @@
1
+ """Command-line interface for the phue library."""
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+ from phue2 import Bridge, PhueRegistrationException
10
+ from phue2._internal.console import (
11
+ BLUE,
12
+ BOLD,
13
+ CYAN,
14
+ GREEN,
15
+ MAGENTA,
16
+ RED,
17
+ YELLOW,
18
+ console,
19
+ styled_text,
20
+ )
21
+
22
+ DISABLE_STYLING = False
23
+
24
+
25
+ def styled_for_cli(text: str, style: str) -> str:
26
+ """Apply styling if enabled, otherwise return plain text.
27
+
28
+ This helper makes tests less brittle by allowing them to match
29
+ on the plain text content.
30
+
31
+ Args:
32
+ text: Text to style
33
+ style: Style to apply
34
+
35
+ Returns:
36
+ Styled text if styling is enabled, otherwise plain text
37
+ """
38
+ if DISABLE_STYLING:
39
+ return text
40
+ return styled_text(text, style)
41
+
42
+
43
+ def parse_args(
44
+ argv: list[str] | None = None,
45
+ ) -> tuple[argparse.ArgumentParser, argparse.Namespace]:
46
+ """Parse command line arguments.
47
+
48
+ Args:
49
+ argv: Command line arguments (defaults to sys.argv[1:])
50
+
51
+ Returns:
52
+ Tuple of (parser, parsed_args)
53
+ """
54
+ # Set up argument parser
55
+ parser = argparse.ArgumentParser(
56
+ description="Control Philips Hue lights from the command line"
57
+ )
58
+ parser.add_argument(
59
+ "--host", help="IP address of the Hue bridge (auto-detected if not provided)"
60
+ )
61
+ parser.add_argument("--config-file-path", help="Path to the config file")
62
+ parser.add_argument("--debug", action="store_true", help="Enable debug logging")
63
+
64
+ # Command subparsers
65
+ subparsers = parser.add_subparsers(dest="command", help="Command to execute")
66
+
67
+ # List command (with ls alias)
68
+ list_parser = subparsers.add_parser(
69
+ "list", aliases=["ls"], help="List available resources"
70
+ )
71
+ list_parser.add_argument(
72
+ "resource",
73
+ nargs="?",
74
+ choices=["lights", "groups", "scenes"],
75
+ default="lights",
76
+ help="Resource type to list",
77
+ )
78
+
79
+ # Get command
80
+ get_parser = subparsers.add_parser("get", help="Get resource details")
81
+ get_parser.add_argument(
82
+ "resource", choices=["light", "group", "scene"], help="Resource type"
83
+ )
84
+ get_parser.add_argument("name", help="Resource name or ID")
85
+
86
+ # Set command
87
+ set_parser = subparsers.add_parser("set", help="Set resource state")
88
+ set_parser.add_argument(
89
+ "resource", choices=["light", "group"], help="Resource type"
90
+ )
91
+ set_parser.add_argument("name", help="Resource name or ID")
92
+ set_parser.add_argument("--on", action="store_true", help="Turn on")
93
+ set_parser.add_argument("--off", action="store_true", help="Turn off")
94
+ set_parser.add_argument("--bri", type=int, help="Set brightness (0-254)")
95
+ set_parser.add_argument("--hue", type=int, help="Set hue (0-65535)")
96
+ set_parser.add_argument("--sat", type=int, help="Set saturation (0-254)")
97
+
98
+ # Parse arguments
99
+ return parser, parser.parse_args(argv)
100
+
101
+
102
+ def get_bridge_from_config(config_path: str | None = None) -> Bridge | None:
103
+ """Attempt to create a Bridge using existing config.
104
+
105
+ Args:
106
+ config_path: Path to config file (uses default if None)
107
+
108
+ Returns:
109
+ Bridge instance if successful, None otherwise
110
+ """
111
+ if not config_path:
112
+ config_path = os.path.expanduser("~/.python_hue")
113
+
114
+ if not os.path.exists(config_path):
115
+ return None
116
+
117
+ try:
118
+ with open(config_path) as f:
119
+ config = json.loads(f.read())
120
+
121
+ # Try each bridge in the config
122
+ for ip in config:
123
+ if "username" in config[ip]:
124
+ try:
125
+ bridge = Bridge(ip=ip, config_file_path=config_path)
126
+ console.info(
127
+ f"{styled_for_cli('Using saved connection to bridge at', MAGENTA)} {styled_for_cli(ip, YELLOW)}"
128
+ )
129
+ return bridge
130
+ except Exception:
131
+ continue
132
+ except Exception:
133
+ pass
134
+
135
+ return None
136
+
137
+
138
+ def main(argv: list[str] | None = None) -> int:
139
+ """Run the phue command-line interface.
140
+
141
+ Args:
142
+ argv: Command line arguments (defaults to sys.argv[1:])
143
+
144
+ Returns:
145
+ Exit code
146
+ """
147
+ # Parse arguments
148
+ parser, args = parse_args(argv)
149
+
150
+ # Configure logging
151
+ log_level = logging.DEBUG if args.debug else logging.WARNING
152
+ logging.basicConfig(level=log_level)
153
+
154
+ # Get bridge - first try config if no host specified
155
+ bridge = None
156
+ if not args.host:
157
+ bridge = get_bridge_from_config(args.config_file_path)
158
+
159
+ # If not connected and host provided, connect to specified host
160
+ if not bridge and args.host:
161
+ console.info(f"Connecting to bridge at {args.host}...")
162
+ while True:
163
+ try:
164
+ bridge = Bridge(args.host, config_file_path=args.config_file_path)
165
+ console.success("Successfully connected to the bridge!")
166
+ break
167
+ except PhueRegistrationException:
168
+ console.warning(
169
+ "Link button not pressed. Press the link button on your bridge."
170
+ )
171
+ input("Press Enter to try again...")
172
+ except Exception as e:
173
+ console.error(f"Failed to connect to the bridge: {e}")
174
+ return 1
175
+
176
+ # If we still don't have a bridge but command specified, error out
177
+ if not bridge and args.command:
178
+ console.error(
179
+ "No bridge connection available. Please specify --host or ensure config file exists."
180
+ )
181
+ return 1
182
+
183
+ # If we still don't have a bridge and no command specified, show general help
184
+ if not bridge and not args.command:
185
+ console.error(
186
+ "No bridge connection available. Please specify --host or ensure config file exists."
187
+ )
188
+ args_parser = argparse.ArgumentParser(
189
+ description="Control Philips Hue lights from the command line"
190
+ )
191
+ args_parser.print_help()
192
+ return 1
193
+
194
+ # If no command specified but we have a bridge, show command help
195
+ if bridge and not args.command:
196
+ console.info(
197
+ f"{styled_for_cli('Connected to bridge.', GREEN)} {styled_for_cli('Use a command to continue.', CYAN)}"
198
+ )
199
+ parser.print_help()
200
+ return 0
201
+
202
+ # Process commands
203
+
204
+ # Handle list/ls command
205
+ if args.command in ["list", "ls"]:
206
+ if not bridge:
207
+ console.error("No bridge connection available")
208
+ return 1
209
+
210
+ if args.resource == "lights":
211
+ lights = bridge.lights
212
+ console.info(styled_for_cli(f"LIGHTS ({len(lights)}):", YELLOW + BOLD))
213
+ for light in lights:
214
+ status = "ON" if light.on else "OFF"
215
+ status_styled = styled_for_cli(status, GREEN if light.on else RED)
216
+ name = str(light.name)
217
+ name_styled = styled_for_cli(name, CYAN)
218
+ console.info(f" {name_styled:<25} {status_styled}")
219
+
220
+ elif args.resource == "groups":
221
+ groups = bridge.groups
222
+ console.info(styled_for_cli(f"GROUPS ({len(groups)}):", YELLOW + BOLD))
223
+ for group in groups:
224
+ name = str(group.name)
225
+ name_styled = styled_for_cli(name, CYAN)
226
+ console.info(f" {name_styled}")
227
+
228
+ elif args.resource == "scenes":
229
+ scenes = bridge.scenes
230
+ console.info(styled_for_cli(f"SCENES ({len(scenes)}):", YELLOW + BOLD))
231
+ for scene in scenes:
232
+ name = str(scene.name)
233
+ name_styled = styled_for_cli(name, CYAN)
234
+ console.info(f" {name_styled}")
235
+
236
+ # Handle get command
237
+ elif args.command == "get":
238
+ if not bridge:
239
+ console.error("No bridge connection available")
240
+ return 1
241
+
242
+ resource = args.resource
243
+ name = args.name
244
+
245
+ if resource == "light":
246
+ # Try by ID first, then by name
247
+ light = None
248
+ try:
249
+ light_id = int(name)
250
+ light = bridge.lights_by_id.get(light_id)
251
+ except ValueError:
252
+ pass
253
+
254
+ if not light:
255
+ light = bridge.lights_by_name.get(name)
256
+
257
+ if not light:
258
+ console.error(f"Light '{name}' not found")
259
+ return 1
260
+
261
+ console.info(styled_for_cli(f"LIGHT: {light.name}", YELLOW + BOLD))
262
+ console.info(
263
+ f" {styled_for_cli('Status:', BLUE)} {styled_for_cli('ON' if light.on else 'OFF', GREEN if light.on else RED)}"
264
+ )
265
+ console.info(f" {styled_for_cli('Type:', BLUE)} {light.type}")
266
+ console.info(
267
+ f" {styled_for_cli('Brightness:', BLUE)} {light.brightness}/254"
268
+ )
269
+ console.info(f" {styled_for_cli('Hue:', BLUE)} {light.hue}/65535")
270
+ console.info(
271
+ f" {styled_for_cli('Saturation:', BLUE)} {light.saturation}/254"
272
+ )
273
+ console.info(f" {styled_for_cli('Reachable:', BLUE)} {light.reachable}")
274
+
275
+ elif resource == "group":
276
+ # Try to find group by name
277
+ group_id = None
278
+ try:
279
+ group_id = bridge.get_group_id_by_name(name)
280
+ except Exception:
281
+ pass
282
+
283
+ if group_id is None:
284
+ try:
285
+ group_id = int(name)
286
+ except ValueError:
287
+ group_id = None
288
+
289
+ if group_id is None:
290
+ console.error(f"Group '{name}' not found")
291
+ return 1
292
+
293
+ group = bridge.get_group(group_id)
294
+
295
+ console.info(styled_for_cli(f"GROUP: {group['name']}", YELLOW + BOLD))
296
+ console.info(f" {styled_for_cli('Type:', BLUE)} {group['type']}")
297
+ console.info(
298
+ f" {styled_for_cli('Lights:', BLUE)} {', '.join(group['lights'])}"
299
+ )
300
+
301
+ elif resource == "scene":
302
+ # This needs bridge.get_scene() implementation which isn't shown
303
+ console.error("Scene details not yet implemented")
304
+ return 1
305
+
306
+ # Handle set command
307
+ elif args.command == "set":
308
+ if not bridge:
309
+ console.error("No bridge connection available")
310
+ return 1
311
+
312
+ resource = args.resource
313
+ name = args.name
314
+ changed = False
315
+
316
+ if resource == "light":
317
+ # Try by ID first, then by name
318
+ light = None
319
+ try:
320
+ light_id = int(name)
321
+ light = bridge.lights_by_id.get(light_id)
322
+ except ValueError:
323
+ pass
324
+
325
+ if not light:
326
+ light = bridge.lights_by_name.get(name)
327
+
328
+ if not light:
329
+ console.error(f"Light '{name}' not found")
330
+ return 1
331
+
332
+ # Apply state changes
333
+ if args.on:
334
+ light.on = True
335
+ changed = True
336
+ elif args.off:
337
+ light.on = False
338
+ changed = True
339
+
340
+ if args.bri is not None:
341
+ light.brightness = args.bri
342
+ changed = True
343
+
344
+ if args.hue is not None:
345
+ light.hue = args.hue
346
+ changed = True
347
+
348
+ if args.sat is not None:
349
+ light.saturation = args.sat
350
+ changed = True
351
+
352
+ if changed:
353
+ console.success(f"Updated light '{light.name}'")
354
+ else:
355
+ console.warning("No changes specified")
356
+
357
+ elif resource == "group":
358
+ # Try to find group by name
359
+ group_id = None
360
+ try:
361
+ group_id = bridge.get_group_id_by_name(name)
362
+ except Exception:
363
+ pass
364
+
365
+ if group_id is None:
366
+ try:
367
+ group_id = int(name)
368
+ except ValueError:
369
+ group_id = None
370
+
371
+ if group_id is None:
372
+ console.error(f"Group '{name}' not found")
373
+ return 1
374
+
375
+ # Apply state changes
376
+ state_changes: dict[str, object] = {}
377
+ changed = False
378
+
379
+ if args.on:
380
+ state_changes["on"] = True
381
+ changed = True
382
+ elif args.off:
383
+ state_changes["on"] = False
384
+ changed = True
385
+
386
+ if args.bri is not None:
387
+ state_changes["bri"] = args.bri
388
+ changed = True
389
+
390
+ if args.hue is not None:
391
+ state_changes["hue"] = args.hue
392
+ changed = True
393
+
394
+ if args.sat is not None:
395
+ state_changes["sat"] = args.sat
396
+ changed = True
397
+
398
+ if changed:
399
+ bridge.set_group(group_id, state_changes)
400
+ console.success(f"Updated group {name}")
401
+ else:
402
+ console.warning("No changes specified")
403
+
404
+ return 0
405
+
406
+
407
+ if __name__ == "__main__":
408
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Utilities for the phue package."""
@@ -0,0 +1,175 @@
1
+ """Terminal utilities for displaying colorful output in the console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ from collections.abc import Callable
7
+ from typing import TypeVar
8
+
9
+ # Check if we're running on Windows
10
+ COLORS_ENABLED = platform.system() != "Windows"
11
+
12
+ # ANSI color/style codes - used only if COLORS_ENABLED is True
13
+ RESET = "\033[0m"
14
+ BOLD = "\033[1m"
15
+ GREEN = "\033[32m"
16
+ YELLOW = "\033[33m"
17
+ RED = "\033[31m"
18
+ BLUE = "\033[34m"
19
+ CYAN = "\033[36m"
20
+ MAGENTA = "\033[35m"
21
+
22
+
23
+ def styled_text(text: str, *styles: str) -> str:
24
+ """Apply ANSI styles to text if colors are enabled.
25
+
26
+ Args:
27
+ text: The text to style
28
+ *styles: ANSI style codes to apply
29
+
30
+ Returns:
31
+ The styled text, or the original text if colors are disabled
32
+ """
33
+ if not COLORS_ENABLED or not styles:
34
+ return text
35
+
36
+ style = "".join(styles)
37
+ return f"{style}{text}{RESET}"
38
+
39
+
40
+ def create_printer(style: str) -> Callable[[str], None]:
41
+ """Create a function that prints text with the given style.
42
+
43
+ Args:
44
+ style: ANSI style code (will be ignored on Windows)
45
+
46
+ Returns:
47
+ A function that prints text with the given style if supported
48
+ """
49
+
50
+ def printer(text: str) -> None:
51
+ print(styled_text(text, style))
52
+
53
+ return printer
54
+
55
+
56
+ print_success = create_printer(f"{GREEN}{BOLD}")
57
+ print_info = create_printer(CYAN)
58
+ print_error = create_printer(f"{RED}{BOLD}")
59
+ print_warning = create_printer(f"{YELLOW}{BOLD}")
60
+ print_header = create_printer(f"{BLUE}{BOLD}")
61
+
62
+
63
+ T = TypeVar("T")
64
+
65
+
66
+ class TerminalUI:
67
+ """A simple UI class for terminal-based interfaces."""
68
+
69
+ @staticmethod
70
+ def header(title: str) -> None:
71
+ """Print a header with a title.
72
+
73
+ Args:
74
+ title: The title to display
75
+ """
76
+ print(f"\n{styled_text('╔' + '═' * 50 + '╗', BLUE, BOLD)}")
77
+ print(
78
+ f"{styled_text('║', BLUE, BOLD)} {styled_text(title.center(48), CYAN, BOLD)} {styled_text('║', BLUE, BOLD)}"
79
+ )
80
+ print(f"{styled_text('╚' + '═' * 50 + '╝', BLUE, BOLD)}")
81
+
82
+ @staticmethod
83
+ def section(title: str) -> None:
84
+ """Print a section divider with a title.
85
+
86
+ Args:
87
+ title: The title to display
88
+ """
89
+ print(f"\n{styled_text(f'▓▒░ {title} ░▒▓', YELLOW, BOLD)}")
90
+
91
+ @staticmethod
92
+ def success(message: str) -> None:
93
+ """Print a success message.
94
+
95
+ Args:
96
+ message: The message to display
97
+ """
98
+ print(f"{styled_text(f'✓ {message}', GREEN, BOLD)}")
99
+
100
+ @staticmethod
101
+ def info(message: str) -> None:
102
+ """Print an info message.
103
+
104
+ Args:
105
+ message: The message to display
106
+ """
107
+ print(f"{styled_text(message, CYAN)}")
108
+
109
+ @staticmethod
110
+ def error(message: str) -> None:
111
+ """Print an error message.
112
+
113
+ Args:
114
+ message: The message to display
115
+ """
116
+ print(f"{styled_text(f'✗ {message}', RED, BOLD)}")
117
+
118
+ @staticmethod
119
+ def warning(message: str) -> None:
120
+ """Print a warning message.
121
+
122
+ Args:
123
+ message: The message to display
124
+ """
125
+ print(f"{styled_text(f'⚠ {message}', YELLOW, BOLD)}")
126
+
127
+ @staticmethod
128
+ def box(title: str, messages: list[str], style: str = MAGENTA) -> None:
129
+ """Print a box with a title and messages.
130
+
131
+ Args:
132
+ title: The box title
133
+ messages: The messages to display inside the box
134
+ style: ANSI style code for the box
135
+ """
136
+ # Find the longest message to size the box
137
+ width = max(len(title), max(len(m) for m in messages)) + 4
138
+
139
+ # Print the box
140
+ print(
141
+ f"{styled_text(f'┌─ {title} ' + '─' * (width - len(title) - 4) + '┐', style, BOLD)}"
142
+ )
143
+ for msg in messages:
144
+ print(
145
+ f"{styled_text(f'│ {msg}' + ' ' * (width - len(msg) - 2) + '│', style, BOLD)}"
146
+ )
147
+ print(f"{styled_text('└' + '─' * (width - 2) + '┘', style, BOLD)}")
148
+
149
+ @staticmethod
150
+ def table(
151
+ items: list[T], formatter: Callable[[T], str], border_style: str = CYAN
152
+ ) -> None:
153
+ """Print a simple table of items.
154
+
155
+ Args:
156
+ items: List of items to display
157
+ formatter: Function to format each item
158
+ border_style: ANSI style code for the table border
159
+ """
160
+ if not items:
161
+ print(styled_text("No items to display", CYAN))
162
+ return
163
+
164
+ # Create header and footer
165
+ print(f"{styled_text('╭─ Items ' + '─' * 40 + '╮', border_style)}")
166
+
167
+ # Print each item
168
+ for item in items:
169
+ print(f"{styled_text('│', border_style)} {formatter(item)}")
170
+
171
+ # Footer
172
+ print(f"{styled_text('╰' + '─' * 48 + '╯', border_style)}")
173
+
174
+
175
+ console = TerminalUI()