fastled 1.3.20__py3-none-any.whl → 1.3.21__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.
fastled/client_server.py CHANGED
@@ -1,519 +1,519 @@
1
- import shutil
2
- import tempfile
3
- import threading
4
- import time
5
- import warnings
6
- from multiprocessing import Process
7
- from pathlib import Path
8
-
9
- from fastled.compile_server import CompileServer
10
- from fastled.docker_manager import DockerManager
11
- from fastled.filewatcher import DebouncedFileWatcherProcess, FileWatcherProcess
12
- from fastled.keyboard import SpaceBarWatcher
13
- from fastled.open_browser import spawn_http_server
14
- from fastled.parse_args import Args
15
- from fastled.settings import DEFAULT_URL, IMAGE_NAME
16
- from fastled.sketch import looks_like_sketch_directory
17
- from fastled.types import BuildMode, CompileResult, CompileServerError
18
- from fastled.web_compile import (
19
- SERVER_PORT,
20
- ConnectionResult,
21
- find_good_connection,
22
- web_compile,
23
- )
24
-
25
-
26
- def _create_error_html(error_message: str) -> str:
27
- return f"""<!DOCTYPE html>
28
- <html>
29
- <head>
30
- <!-- no cache -->
31
- <meta http-equiv="Cache-Control" content="no-store" />
32
- <meta http-equiv="Pragma" content="no-cache" />
33
- <meta http-equiv="Expires" content="0" />
34
- <title>FastLED Compilation Error ZACH</title>
35
- <style>
36
- body {{
37
- background-color: #1a1a1a;
38
- color: #ffffff;
39
- font-family: Arial, sans-serif;
40
- margin: 20px;
41
- padding: 20px;
42
- }}
43
- pre {{
44
- color: #ffffff;
45
- background-color: #1a1a1a;
46
- border: 1px solid #444444;
47
- border-radius: 4px;
48
- padding: 15px;
49
- white-space: pre-wrap;
50
- word-wrap: break-word;
51
- }}
52
- </style>
53
- </head>
54
- <body>
55
- <h1>Compilation Failed</h1>
56
- <pre>{error_message}</pre>
57
- </body>
58
- </html>"""
59
-
60
-
61
- # Override this function in your own code to run tests before compilation
62
- def TEST_BEFORE_COMPILE(url) -> None:
63
- pass
64
-
65
-
66
- def _chunked_print(stdout: str) -> None:
67
- lines = stdout.splitlines()
68
- for line in lines:
69
- print(line)
70
-
71
-
72
- def _run_web_compiler(
73
- directory: Path,
74
- host: str,
75
- build_mode: BuildMode,
76
- profile: bool,
77
- last_hash_value: str | None,
78
- ) -> CompileResult:
79
- input_dir = Path(directory)
80
- output_dir = input_dir / "fastled_js"
81
- start = time.time()
82
- web_result = web_compile(
83
- directory=input_dir, host=host, build_mode=build_mode, profile=profile
84
- )
85
- diff = time.time() - start
86
- if not web_result.success:
87
- print("\nWeb compilation failed:")
88
- print(f"Time taken: {diff:.2f} seconds")
89
- _chunked_print(web_result.stdout)
90
- # Create error page
91
- output_dir.mkdir(exist_ok=True)
92
- error_html = _create_error_html(web_result.stdout)
93
- (output_dir / "index.html").write_text(error_html, encoding="utf-8")
94
- return web_result
95
-
96
- def print_results() -> None:
97
- hash_value = (
98
- web_result.hash_value
99
- if web_result.hash_value is not None
100
- else "NO HASH VALUE"
101
- )
102
- print(
103
- f"\nWeb compilation successful\n Time: {diff:.2f}\n output: {output_dir}\n hash: {hash_value}\n zip size: {len(web_result.zip_bytes)} bytes"
104
- )
105
-
106
- # now check to see if the hash value is the same as the last hash value
107
- if last_hash_value is not None and last_hash_value == web_result.hash_value:
108
- print("\nSkipping redeploy: No significant changes found.")
109
- print_results()
110
- return web_result
111
-
112
- # Extract zip contents to fastled_js directory
113
- output_dir.mkdir(exist_ok=True)
114
- with tempfile.TemporaryDirectory() as temp_dir:
115
- temp_path = Path(temp_dir)
116
- temp_zip = temp_path / "result.zip"
117
- temp_zip.write_bytes(web_result.zip_bytes)
118
-
119
- # Clear existing contents
120
- shutil.rmtree(output_dir, ignore_errors=True)
121
- output_dir.mkdir(exist_ok=True)
122
-
123
- # Extract zip contents
124
- shutil.unpack_archive(temp_zip, output_dir, "zip")
125
-
126
- _chunked_print(web_result.stdout)
127
- print_results()
128
- return web_result
129
-
130
-
131
- def _try_start_server_or_get_url(
132
- auto_update: bool, args_web: str | bool, localhost: bool, clear: bool
133
- ) -> tuple[str, CompileServer | None]:
134
- is_local_host = localhost or (
135
- isinstance(args_web, str)
136
- and ("localhost" in args_web or "127.0.0.1" in args_web)
137
- )
138
- # test to see if there is already a local host server
139
- local_host_needs_server = False
140
- if is_local_host:
141
- addr = "localhost" if localhost or not isinstance(args_web, str) else args_web
142
- urls = [addr]
143
- if ":" not in addr:
144
- urls.append(f"{addr}:{SERVER_PORT}")
145
-
146
- result: ConnectionResult | None = find_good_connection(urls)
147
- if result is not None:
148
- print(f"Found local server at {result.host}")
149
- return (result.host, None)
150
- else:
151
- local_host_needs_server = True
152
-
153
- if not local_host_needs_server and args_web:
154
- if isinstance(args_web, str):
155
- return (args_web, None)
156
- if isinstance(args_web, bool):
157
- return (DEFAULT_URL, None)
158
- return (args_web, None)
159
- else:
160
- try:
161
- print("No local server found, starting one...")
162
- compile_server = CompileServer(
163
- auto_updates=auto_update, remove_previous=clear
164
- )
165
- print("Waiting for the local compiler to start...")
166
- if not compile_server.ping():
167
- print("Failed to start local compiler.")
168
- raise CompileServerError("Failed to start local compiler.")
169
- return (compile_server.url(), compile_server)
170
- except KeyboardInterrupt:
171
- raise
172
- except RuntimeError:
173
- print("Failed to start local compile server, using web compiler instead.")
174
- return (DEFAULT_URL, None)
175
-
176
-
177
- def _try_make_compile_server(clear: bool = False) -> CompileServer | None:
178
- if not DockerManager.is_docker_installed():
179
- return None
180
- try:
181
- print(
182
- "\nNo host specified, but Docker is installed, attempting to start a compile server using Docker."
183
- )
184
- from fastled.util import find_free_port
185
-
186
- free_port = find_free_port(start_port=9723, end_port=9743)
187
- if free_port is None:
188
- return None
189
- compile_server = CompileServer(auto_updates=False, remove_previous=clear)
190
- print("Waiting for the local compiler to start...")
191
- if not compile_server.ping():
192
- print("Failed to start local compiler.")
193
- raise CompileServerError("Failed to start local compiler.")
194
- return compile_server
195
- except KeyboardInterrupt:
196
- import _thread
197
-
198
- _thread.interrupt_main()
199
- raise
200
- except Exception as e:
201
- warnings.warn(f"Error starting local compile server: {e}")
202
- return None
203
-
204
-
205
- def _is_local_host(host: str) -> bool:
206
- return (
207
- host.startswith("http://localhost")
208
- or host.startswith("http://127.0.0.1")
209
- or host.startswith("http://0.0.0.0")
210
- or host.startswith("http://[::]")
211
- or host.startswith("http://[::1]")
212
- or host.startswith("http://[::ffff:127.0.0.1]")
213
- )
214
-
215
-
216
- def run_client(
217
- directory: Path,
218
- host: str | CompileServer | None,
219
- open_web_browser: bool = True,
220
- keep_running: bool = True, # if false, only one compilation will be done.
221
- build_mode: BuildMode = BuildMode.QUICK,
222
- profile: bool = False,
223
- shutdown: threading.Event | None = None,
224
- http_port: (
225
- int | None
226
- ) = None, # None means auto select a free port, http_port < 0 means no server.
227
- clear: bool = False,
228
- ) -> int:
229
- has_checked_newer_version_yet = False
230
- compile_server: CompileServer | None = None
231
-
232
- if host is None:
233
- # attempt to start a compile server if docker is installed.
234
- compile_server = _try_make_compile_server(clear=clear)
235
- if compile_server is None:
236
- host = DEFAULT_URL
237
- elif isinstance(host, CompileServer):
238
- # if the host is a compile server, use that
239
- compile_server = host
240
-
241
- shutdown = shutdown or threading.Event()
242
-
243
- def get_url(host=host, compile_server=compile_server) -> str:
244
- if compile_server is not None:
245
- return compile_server.url()
246
- if isinstance(host, str):
247
- return host
248
- return DEFAULT_URL
249
-
250
- url = get_url()
251
- is_local_host = _is_local_host(url)
252
- # parse out the port from the url
253
- # use a standard host address parser to grab it
254
- import urllib.parse
255
-
256
- parsed_url = urllib.parse.urlparse(url)
257
- if parsed_url.port is not None:
258
- port = parsed_url.port
259
- else:
260
- if is_local_host:
261
- raise ValueError(
262
- "Cannot use local host without a port. Please specify a port."
263
- )
264
- # Assume default port for www
265
- port = 80
266
-
267
- try:
268
-
269
- def compile_function(
270
- url: str = url,
271
- build_mode: BuildMode = build_mode,
272
- profile: bool = profile,
273
- last_hash_value: str | None = None,
274
- ) -> CompileResult:
275
- TEST_BEFORE_COMPILE(url)
276
- return _run_web_compiler(
277
- directory,
278
- host=url,
279
- build_mode=build_mode,
280
- profile=profile,
281
- last_hash_value=last_hash_value,
282
- )
283
-
284
- result: CompileResult = compile_function(last_hash_value=None)
285
- last_compiled_result: CompileResult = result
286
-
287
- if not result.success:
288
- print("\nCompilation failed.")
289
-
290
- use_http_server = http_port is None or http_port >= 0
291
- if not use_http_server and open_web_browser:
292
- warnings.warn(
293
- f"Warning: --http-port={http_port} specified but open_web_browser is False, ignoring --http-port."
294
- )
295
- use_http_server = False
296
-
297
- http_proc: Process | None = None
298
- if use_http_server:
299
- http_proc = spawn_http_server(
300
- directory / "fastled_js",
301
- port=http_port,
302
- compile_server_port=port,
303
- open_browser=open_web_browser,
304
- )
305
- else:
306
- print("\nCompilation successful.")
307
- if compile_server:
308
- print("Shutting down compile server...")
309
- compile_server.stop()
310
- return 0
311
-
312
- if not keep_running or shutdown.is_set():
313
- if http_proc:
314
- http_proc.kill()
315
- return 0 if result.success else 1
316
- except KeyboardInterrupt:
317
- print("\nExiting from main")
318
- return 1
319
-
320
- excluded_patterns = ["fastled_js"]
321
- debounced_sketch_watcher = DebouncedFileWatcherProcess(
322
- FileWatcherProcess(directory, excluded_patterns=excluded_patterns),
323
- )
324
-
325
- source_code_watcher: FileWatcherProcess | None = None
326
- if compile_server and compile_server.using_fastled_src_dir_volume():
327
- assert compile_server.fastled_src_dir is not None
328
- source_code_watcher = FileWatcherProcess(
329
- compile_server.fastled_src_dir, excluded_patterns=excluded_patterns
330
- )
331
-
332
- def trigger_rebuild_if_sketch_changed(
333
- last_compiled_result: CompileResult,
334
- ) -> tuple[bool, CompileResult]:
335
- changed_files = debounced_sketch_watcher.get_all_changes()
336
- if changed_files:
337
- print(f"\nChanges detected in {changed_files}")
338
- last_hash_value = last_compiled_result.hash_value
339
- out = compile_function(last_hash_value=last_hash_value)
340
- if not out.success:
341
- print("\nRecompilation failed.")
342
- else:
343
- print("\nRecompilation successful.")
344
- return True, out
345
- return False, last_compiled_result
346
-
347
- def print_status() -> None:
348
- print("Will compile on sketch changes or if you hit the space bar.")
349
-
350
- print_status()
351
- print("Press Ctrl+C to stop...")
352
-
353
- try:
354
- while True:
355
- if shutdown.is_set():
356
- print("\nStopping watch mode...")
357
- return 0
358
-
359
- # Check for newer Docker image version after first successful compilation
360
- if (
361
- not has_checked_newer_version_yet
362
- and last_compiled_result.success
363
- and is_local_host
364
- ):
365
- has_checked_newer_version_yet = True
366
- try:
367
-
368
- docker_manager = DockerManager()
369
- has_update, message = docker_manager.has_newer_version(
370
- image_name=IMAGE_NAME, tag="latest"
371
- )
372
- if has_update:
373
- print(f"\n🔄 {message}")
374
- print(
375
- "Run with --auto-update to automatically update to the latest version."
376
- )
377
- except Exception as e:
378
- # Don't let Docker check failures interrupt the main flow
379
- warnings.warn(f"Failed to check for Docker image updates: {e}")
380
-
381
- if SpaceBarWatcher.watch_space_bar_pressed(timeout=1.0):
382
- print("Compiling...")
383
- last_compiled_result = compile_function(last_hash_value=None)
384
- if not last_compiled_result.success:
385
- print("\nRecompilation failed.")
386
- else:
387
- print("\nRecompilation successful.")
388
- # drain the space bar queue
389
- SpaceBarWatcher.watch_space_bar_pressed()
390
- print_status()
391
- continue
392
- changed, last_compiled_result = trigger_rebuild_if_sketch_changed(
393
- last_compiled_result
394
- )
395
- if changed:
396
- print_status()
397
- continue
398
- if compile_server and not compile_server.process_running():
399
- print("Server process is not running. Exiting...")
400
- return 1
401
- if source_code_watcher is not None:
402
- changed_files = source_code_watcher.get_all_changes()
403
- # de-duplicate changes
404
- changed_files = sorted(list(set(changed_files)))
405
- if changed_files:
406
- print(f"\nChanges detected in FastLED source code: {changed_files}")
407
- print("Press space bar to trigger compile.")
408
- while True:
409
- space_bar_pressed = SpaceBarWatcher.watch_space_bar_pressed(
410
- timeout=1.0
411
- )
412
- file_changes = source_code_watcher.get_all_changes()
413
- sketch_files_changed = (
414
- debounced_sketch_watcher.get_all_changes()
415
- )
416
-
417
- if file_changes:
418
- print(
419
- f"Changes detected in {file_changes}\nHit the space bar to trigger compile."
420
- )
421
-
422
- if space_bar_pressed or sketch_files_changed:
423
- if space_bar_pressed:
424
- print("Space bar pressed, triggering recompile...")
425
- elif sketch_files_changed:
426
- print(
427
- f"Changes detected in {','.join(sketch_files_changed)}, triggering recompile..."
428
- )
429
- last_compiled_result = compile_function(
430
- last_hash_value=None
431
- )
432
- print("Finished recompile.")
433
- # Drain the space bar queue
434
- SpaceBarWatcher.watch_space_bar_pressed()
435
- print_status()
436
- continue
437
-
438
- except KeyboardInterrupt:
439
- print("\nStopping watch mode...")
440
- return 0
441
- except Exception as e:
442
- print(f"Error: {e}")
443
- return 1
444
- finally:
445
- debounced_sketch_watcher.stop()
446
- if compile_server:
447
- compile_server.stop()
448
- if http_proc:
449
- http_proc.kill()
450
-
451
-
452
- def run_client_server(args: Args) -> int:
453
- profile = bool(args.profile)
454
- web: str | bool = args.web if isinstance(args.web, str) else bool(args.web)
455
- auto_update = bool(args.auto_update)
456
- localhost = bool(args.localhost)
457
- directory = args.directory if args.directory else Path(".")
458
- just_compile = bool(args.just_compile)
459
- interactive = bool(args.interactive)
460
- force_compile = bool(args.force_compile)
461
- open_web_browser = not just_compile and not interactive
462
- build_mode: BuildMode = BuildMode.from_args(args)
463
-
464
- if not force_compile and not looks_like_sketch_directory(directory):
465
- # if there is only one directory in the sketch directory, use that
466
- found_valid_child = False
467
- if len(list(directory.iterdir())) == 1:
468
- child_dir = next(directory.iterdir())
469
- if looks_like_sketch_directory(child_dir):
470
- found_valid_child = True
471
- print(
472
- f"The selected directory is not a valid FastLED sketch directory, the child directory {child_dir} looks like a sketch directory, using that instead."
473
- )
474
- directory = child_dir
475
- if not found_valid_child:
476
- print(
477
- f"Error: {directory} is not a valid FastLED sketch directory, if you are sure it is, use --force-compile"
478
- )
479
- return 1
480
-
481
- # If not explicitly using web compiler, check Docker installation
482
- if not web and not DockerManager.is_docker_installed():
483
- print(
484
- "\nDocker is not installed on this system - switching to web compiler instead."
485
- )
486
- web = True
487
-
488
- url: str
489
- compile_server: CompileServer | None = None
490
- try:
491
- url, compile_server = _try_start_server_or_get_url(
492
- auto_update, web, localhost, args.clear
493
- )
494
- except KeyboardInterrupt:
495
- print("\nExiting from first try...")
496
- if compile_server is not None:
497
- compile_server.stop()
498
- return 1
499
- except Exception as e:
500
- print(f"Error: {e}")
501
- if compile_server is not None:
502
- compile_server.stop()
503
- return 1
504
-
505
- try:
506
- return run_client(
507
- directory=directory,
508
- host=compile_server if compile_server else url,
509
- open_web_browser=open_web_browser,
510
- keep_running=not just_compile,
511
- build_mode=build_mode,
512
- profile=profile,
513
- clear=args.clear,
514
- )
515
- except KeyboardInterrupt:
516
- return 1
517
- finally:
518
- if compile_server:
519
- compile_server.stop()
1
+ import shutil
2
+ import tempfile
3
+ import threading
4
+ import time
5
+ import warnings
6
+ from multiprocessing import Process
7
+ from pathlib import Path
8
+
9
+ from fastled.compile_server import CompileServer
10
+ from fastled.docker_manager import DockerManager
11
+ from fastled.filewatcher import DebouncedFileWatcherProcess, FileWatcherProcess
12
+ from fastled.keyboard import SpaceBarWatcher
13
+ from fastled.open_browser import spawn_http_server
14
+ from fastled.parse_args import Args
15
+ from fastled.settings import DEFAULT_URL, IMAGE_NAME
16
+ from fastled.sketch import looks_like_sketch_directory
17
+ from fastled.types import BuildMode, CompileResult, CompileServerError
18
+ from fastled.web_compile import (
19
+ SERVER_PORT,
20
+ ConnectionResult,
21
+ find_good_connection,
22
+ web_compile,
23
+ )
24
+
25
+
26
+ def _create_error_html(error_message: str) -> str:
27
+ return f"""<!DOCTYPE html>
28
+ <html>
29
+ <head>
30
+ <!-- no cache -->
31
+ <meta http-equiv="Cache-Control" content="no-store" />
32
+ <meta http-equiv="Pragma" content="no-cache" />
33
+ <meta http-equiv="Expires" content="0" />
34
+ <title>FastLED Compilation Error ZACH</title>
35
+ <style>
36
+ body {{
37
+ background-color: #1a1a1a;
38
+ color: #ffffff;
39
+ font-family: Arial, sans-serif;
40
+ margin: 20px;
41
+ padding: 20px;
42
+ }}
43
+ pre {{
44
+ color: #ffffff;
45
+ background-color: #1a1a1a;
46
+ border: 1px solid #444444;
47
+ border-radius: 4px;
48
+ padding: 15px;
49
+ white-space: pre-wrap;
50
+ word-wrap: break-word;
51
+ }}
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <h1>Compilation Failed</h1>
56
+ <pre>{error_message}</pre>
57
+ </body>
58
+ </html>"""
59
+
60
+
61
+ # Override this function in your own code to run tests before compilation
62
+ def TEST_BEFORE_COMPILE(url) -> None:
63
+ pass
64
+
65
+
66
+ def _chunked_print(stdout: str) -> None:
67
+ lines = stdout.splitlines()
68
+ for line in lines:
69
+ print(line)
70
+
71
+
72
+ def _run_web_compiler(
73
+ directory: Path,
74
+ host: str,
75
+ build_mode: BuildMode,
76
+ profile: bool,
77
+ last_hash_value: str | None,
78
+ ) -> CompileResult:
79
+ input_dir = Path(directory)
80
+ output_dir = input_dir / "fastled_js"
81
+ start = time.time()
82
+ web_result = web_compile(
83
+ directory=input_dir, host=host, build_mode=build_mode, profile=profile
84
+ )
85
+ diff = time.time() - start
86
+ if not web_result.success:
87
+ print("\nWeb compilation failed:")
88
+ print(f"Time taken: {diff:.2f} seconds")
89
+ _chunked_print(web_result.stdout)
90
+ # Create error page
91
+ output_dir.mkdir(exist_ok=True)
92
+ error_html = _create_error_html(web_result.stdout)
93
+ (output_dir / "index.html").write_text(error_html, encoding="utf-8")
94
+ return web_result
95
+
96
+ def print_results() -> None:
97
+ hash_value = (
98
+ web_result.hash_value
99
+ if web_result.hash_value is not None
100
+ else "NO HASH VALUE"
101
+ )
102
+ print(
103
+ f"\nWeb compilation successful\n Time: {diff:.2f}\n output: {output_dir}\n hash: {hash_value}\n zip size: {len(web_result.zip_bytes)} bytes"
104
+ )
105
+
106
+ # now check to see if the hash value is the same as the last hash value
107
+ if last_hash_value is not None and last_hash_value == web_result.hash_value:
108
+ print("\nSkipping redeploy: No significant changes found.")
109
+ print_results()
110
+ return web_result
111
+
112
+ # Extract zip contents to fastled_js directory
113
+ output_dir.mkdir(exist_ok=True)
114
+ with tempfile.TemporaryDirectory() as temp_dir:
115
+ temp_path = Path(temp_dir)
116
+ temp_zip = temp_path / "result.zip"
117
+ temp_zip.write_bytes(web_result.zip_bytes)
118
+
119
+ # Clear existing contents
120
+ shutil.rmtree(output_dir, ignore_errors=True)
121
+ output_dir.mkdir(exist_ok=True)
122
+
123
+ # Extract zip contents
124
+ shutil.unpack_archive(temp_zip, output_dir, "zip")
125
+
126
+ _chunked_print(web_result.stdout)
127
+ print_results()
128
+ return web_result
129
+
130
+
131
+ def _try_start_server_or_get_url(
132
+ auto_update: bool, args_web: str | bool, localhost: bool, clear: bool
133
+ ) -> tuple[str, CompileServer | None]:
134
+ is_local_host = localhost or (
135
+ isinstance(args_web, str)
136
+ and ("localhost" in args_web or "127.0.0.1" in args_web)
137
+ )
138
+ # test to see if there is already a local host server
139
+ local_host_needs_server = False
140
+ if is_local_host:
141
+ addr = "localhost" if localhost or not isinstance(args_web, str) else args_web
142
+ urls = [addr]
143
+ if ":" not in addr:
144
+ urls.append(f"{addr}:{SERVER_PORT}")
145
+
146
+ result: ConnectionResult | None = find_good_connection(urls)
147
+ if result is not None:
148
+ print(f"Found local server at {result.host}")
149
+ return (result.host, None)
150
+ else:
151
+ local_host_needs_server = True
152
+
153
+ if not local_host_needs_server and args_web:
154
+ if isinstance(args_web, str):
155
+ return (args_web, None)
156
+ if isinstance(args_web, bool):
157
+ return (DEFAULT_URL, None)
158
+ return (args_web, None)
159
+ else:
160
+ try:
161
+ print("No local server found, starting one...")
162
+ compile_server = CompileServer(
163
+ auto_updates=auto_update, remove_previous=clear
164
+ )
165
+ print("Waiting for the local compiler to start...")
166
+ if not compile_server.ping():
167
+ print("Failed to start local compiler.")
168
+ raise CompileServerError("Failed to start local compiler.")
169
+ return (compile_server.url(), compile_server)
170
+ except KeyboardInterrupt:
171
+ raise
172
+ except RuntimeError:
173
+ print("Failed to start local compile server, using web compiler instead.")
174
+ return (DEFAULT_URL, None)
175
+
176
+
177
+ def _try_make_compile_server(clear: bool = False) -> CompileServer | None:
178
+ if not DockerManager.is_docker_installed():
179
+ return None
180
+ try:
181
+ print(
182
+ "\nNo host specified, but Docker is installed, attempting to start a compile server using Docker."
183
+ )
184
+ from fastled.util import find_free_port
185
+
186
+ free_port = find_free_port(start_port=9723, end_port=9743)
187
+ if free_port is None:
188
+ return None
189
+ compile_server = CompileServer(auto_updates=False, remove_previous=clear)
190
+ print("Waiting for the local compiler to start...")
191
+ if not compile_server.ping():
192
+ print("Failed to start local compiler.")
193
+ raise CompileServerError("Failed to start local compiler.")
194
+ return compile_server
195
+ except KeyboardInterrupt:
196
+ import _thread
197
+
198
+ _thread.interrupt_main()
199
+ raise
200
+ except Exception as e:
201
+ warnings.warn(f"Error starting local compile server: {e}")
202
+ return None
203
+
204
+
205
+ def _is_local_host(host: str) -> bool:
206
+ return (
207
+ host.startswith("http://localhost")
208
+ or host.startswith("http://127.0.0.1")
209
+ or host.startswith("http://0.0.0.0")
210
+ or host.startswith("http://[::]")
211
+ or host.startswith("http://[::1]")
212
+ or host.startswith("http://[::ffff:127.0.0.1]")
213
+ )
214
+
215
+
216
+ def run_client(
217
+ directory: Path,
218
+ host: str | CompileServer | None,
219
+ open_web_browser: bool = True,
220
+ keep_running: bool = True, # if false, only one compilation will be done.
221
+ build_mode: BuildMode = BuildMode.QUICK,
222
+ profile: bool = False,
223
+ shutdown: threading.Event | None = None,
224
+ http_port: (
225
+ int | None
226
+ ) = None, # None means auto select a free port, http_port < 0 means no server.
227
+ clear: bool = False,
228
+ ) -> int:
229
+ has_checked_newer_version_yet = False
230
+ compile_server: CompileServer | None = None
231
+
232
+ if host is None:
233
+ # attempt to start a compile server if docker is installed.
234
+ compile_server = _try_make_compile_server(clear=clear)
235
+ if compile_server is None:
236
+ host = DEFAULT_URL
237
+ elif isinstance(host, CompileServer):
238
+ # if the host is a compile server, use that
239
+ compile_server = host
240
+
241
+ shutdown = shutdown or threading.Event()
242
+
243
+ def get_url(host=host, compile_server=compile_server) -> str:
244
+ if compile_server is not None:
245
+ return compile_server.url()
246
+ if isinstance(host, str):
247
+ return host
248
+ return DEFAULT_URL
249
+
250
+ url = get_url()
251
+ is_local_host = _is_local_host(url)
252
+ # parse out the port from the url
253
+ # use a standard host address parser to grab it
254
+ import urllib.parse
255
+
256
+ parsed_url = urllib.parse.urlparse(url)
257
+ if parsed_url.port is not None:
258
+ port = parsed_url.port
259
+ else:
260
+ if is_local_host:
261
+ raise ValueError(
262
+ "Cannot use local host without a port. Please specify a port."
263
+ )
264
+ # Assume default port for www
265
+ port = 80
266
+
267
+ try:
268
+
269
+ def compile_function(
270
+ url: str = url,
271
+ build_mode: BuildMode = build_mode,
272
+ profile: bool = profile,
273
+ last_hash_value: str | None = None,
274
+ ) -> CompileResult:
275
+ TEST_BEFORE_COMPILE(url)
276
+ return _run_web_compiler(
277
+ directory,
278
+ host=url,
279
+ build_mode=build_mode,
280
+ profile=profile,
281
+ last_hash_value=last_hash_value,
282
+ )
283
+
284
+ result: CompileResult = compile_function(last_hash_value=None)
285
+ last_compiled_result: CompileResult = result
286
+
287
+ if not result.success:
288
+ print("\nCompilation failed.")
289
+
290
+ use_http_server = http_port is None or http_port >= 0
291
+ if not use_http_server and open_web_browser:
292
+ warnings.warn(
293
+ f"Warning: --http-port={http_port} specified but open_web_browser is False, ignoring --http-port."
294
+ )
295
+ use_http_server = False
296
+
297
+ http_proc: Process | None = None
298
+ if use_http_server:
299
+ http_proc = spawn_http_server(
300
+ directory / "fastled_js",
301
+ port=http_port,
302
+ compile_server_port=port,
303
+ open_browser=open_web_browser,
304
+ )
305
+ else:
306
+ print("\nCompilation successful.")
307
+ if compile_server:
308
+ print("Shutting down compile server...")
309
+ compile_server.stop()
310
+ return 0
311
+
312
+ if not keep_running or shutdown.is_set():
313
+ if http_proc:
314
+ http_proc.kill()
315
+ return 0 if result.success else 1
316
+ except KeyboardInterrupt:
317
+ print("\nExiting from main")
318
+ return 1
319
+
320
+ excluded_patterns = ["fastled_js"]
321
+ debounced_sketch_watcher = DebouncedFileWatcherProcess(
322
+ FileWatcherProcess(directory, excluded_patterns=excluded_patterns),
323
+ )
324
+
325
+ source_code_watcher: FileWatcherProcess | None = None
326
+ if compile_server and compile_server.using_fastled_src_dir_volume():
327
+ assert compile_server.fastled_src_dir is not None
328
+ source_code_watcher = FileWatcherProcess(
329
+ compile_server.fastled_src_dir, excluded_patterns=excluded_patterns
330
+ )
331
+
332
+ def trigger_rebuild_if_sketch_changed(
333
+ last_compiled_result: CompileResult,
334
+ ) -> tuple[bool, CompileResult]:
335
+ changed_files = debounced_sketch_watcher.get_all_changes()
336
+ if changed_files:
337
+ print(f"\nChanges detected in {changed_files}")
338
+ last_hash_value = last_compiled_result.hash_value
339
+ out = compile_function(last_hash_value=last_hash_value)
340
+ if not out.success:
341
+ print("\nRecompilation failed.")
342
+ else:
343
+ print("\nRecompilation successful.")
344
+ return True, out
345
+ return False, last_compiled_result
346
+
347
+ def print_status() -> None:
348
+ print("Will compile on sketch changes or if you hit the space bar.")
349
+
350
+ print_status()
351
+ print("Press Ctrl+C to stop...")
352
+
353
+ try:
354
+ while True:
355
+ if shutdown.is_set():
356
+ print("\nStopping watch mode...")
357
+ return 0
358
+
359
+ # Check for newer Docker image version after first successful compilation
360
+ if (
361
+ not has_checked_newer_version_yet
362
+ and last_compiled_result.success
363
+ and is_local_host
364
+ ):
365
+ has_checked_newer_version_yet = True
366
+ try:
367
+
368
+ docker_manager = DockerManager()
369
+ has_update, message = docker_manager.has_newer_version(
370
+ image_name=IMAGE_NAME, tag="latest"
371
+ )
372
+ if has_update:
373
+ print(f"\n🔄 {message}")
374
+ print(
375
+ "Run with --auto-update to automatically update to the latest version."
376
+ )
377
+ except Exception as e:
378
+ # Don't let Docker check failures interrupt the main flow
379
+ warnings.warn(f"Failed to check for Docker image updates: {e}")
380
+
381
+ if SpaceBarWatcher.watch_space_bar_pressed(timeout=1.0):
382
+ print("Compiling...")
383
+ last_compiled_result = compile_function(last_hash_value=None)
384
+ if not last_compiled_result.success:
385
+ print("\nRecompilation failed.")
386
+ else:
387
+ print("\nRecompilation successful.")
388
+ # drain the space bar queue
389
+ SpaceBarWatcher.watch_space_bar_pressed()
390
+ print_status()
391
+ continue
392
+ changed, last_compiled_result = trigger_rebuild_if_sketch_changed(
393
+ last_compiled_result
394
+ )
395
+ if changed:
396
+ print_status()
397
+ continue
398
+ if compile_server and not compile_server.process_running():
399
+ print("Server process is not running. Exiting...")
400
+ return 1
401
+ if source_code_watcher is not None:
402
+ changed_files = source_code_watcher.get_all_changes()
403
+ # de-duplicate changes
404
+ changed_files = sorted(list(set(changed_files)))
405
+ if changed_files:
406
+ print(f"\nChanges detected in FastLED source code: {changed_files}")
407
+ print("Press space bar to trigger compile.")
408
+ while True:
409
+ space_bar_pressed = SpaceBarWatcher.watch_space_bar_pressed(
410
+ timeout=1.0
411
+ )
412
+ file_changes = source_code_watcher.get_all_changes()
413
+ sketch_files_changed = (
414
+ debounced_sketch_watcher.get_all_changes()
415
+ )
416
+
417
+ if file_changes:
418
+ print(
419
+ f"Changes detected in {file_changes}\nHit the space bar to trigger compile."
420
+ )
421
+
422
+ if space_bar_pressed or sketch_files_changed:
423
+ if space_bar_pressed:
424
+ print("Space bar pressed, triggering recompile...")
425
+ elif sketch_files_changed:
426
+ print(
427
+ f"Changes detected in {','.join(sketch_files_changed)}, triggering recompile..."
428
+ )
429
+ last_compiled_result = compile_function(
430
+ last_hash_value=None
431
+ )
432
+ print("Finished recompile.")
433
+ # Drain the space bar queue
434
+ SpaceBarWatcher.watch_space_bar_pressed()
435
+ print_status()
436
+ continue
437
+
438
+ except KeyboardInterrupt:
439
+ print("\nStopping watch mode...")
440
+ return 0
441
+ except Exception as e:
442
+ print(f"Error: {e}")
443
+ return 1
444
+ finally:
445
+ debounced_sketch_watcher.stop()
446
+ if compile_server:
447
+ compile_server.stop()
448
+ if http_proc:
449
+ http_proc.kill()
450
+
451
+
452
+ def run_client_server(args: Args) -> int:
453
+ profile = bool(args.profile)
454
+ web: str | bool = args.web if isinstance(args.web, str) else bool(args.web)
455
+ auto_update = bool(args.auto_update)
456
+ localhost = bool(args.localhost)
457
+ directory = args.directory if args.directory else Path(".")
458
+ just_compile = bool(args.just_compile)
459
+ interactive = bool(args.interactive)
460
+ force_compile = bool(args.force_compile)
461
+ open_web_browser = not just_compile and not interactive
462
+ build_mode: BuildMode = BuildMode.from_args(args)
463
+
464
+ if not force_compile and not looks_like_sketch_directory(directory):
465
+ # if there is only one directory in the sketch directory, use that
466
+ found_valid_child = False
467
+ if len(list(directory.iterdir())) == 1:
468
+ child_dir = next(directory.iterdir())
469
+ if looks_like_sketch_directory(child_dir):
470
+ found_valid_child = True
471
+ print(
472
+ f"The selected directory is not a valid FastLED sketch directory, the child directory {child_dir} looks like a sketch directory, using that instead."
473
+ )
474
+ directory = child_dir
475
+ if not found_valid_child:
476
+ print(
477
+ f"Error: {directory} is not a valid FastLED sketch directory, if you are sure it is, use --force-compile"
478
+ )
479
+ return 1
480
+
481
+ # If not explicitly using web compiler, check Docker installation
482
+ if not web and not DockerManager.is_docker_installed():
483
+ print(
484
+ "\nDocker is not installed on this system - switching to web compiler instead."
485
+ )
486
+ web = True
487
+
488
+ url: str
489
+ compile_server: CompileServer | None = None
490
+ try:
491
+ url, compile_server = _try_start_server_or_get_url(
492
+ auto_update, web, localhost, args.clear
493
+ )
494
+ except KeyboardInterrupt:
495
+ print("\nExiting from first try...")
496
+ if compile_server is not None:
497
+ compile_server.stop()
498
+ return 1
499
+ except Exception as e:
500
+ print(f"Error: {e}")
501
+ if compile_server is not None:
502
+ compile_server.stop()
503
+ return 1
504
+
505
+ try:
506
+ return run_client(
507
+ directory=directory,
508
+ host=compile_server if compile_server else url,
509
+ open_web_browser=open_web_browser,
510
+ keep_running=not just_compile,
511
+ build_mode=build_mode,
512
+ profile=profile,
513
+ clear=args.clear,
514
+ )
515
+ except KeyboardInterrupt:
516
+ return 1
517
+ finally:
518
+ if compile_server:
519
+ compile_server.stop()