fastled 1.3.30__py3-none-any.whl → 1.4.50__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.
Files changed (47) hide show
  1. fastled/__init__.py +30 -2
  2. fastled/__main__.py +14 -0
  3. fastled/__version__.py +1 -1
  4. fastled/app.py +51 -2
  5. fastled/args.py +33 -0
  6. fastled/client_server.py +188 -40
  7. fastled/compile_server.py +10 -0
  8. fastled/compile_server_impl.py +34 -1
  9. fastled/docker_manager.py +56 -14
  10. fastled/emoji_util.py +27 -0
  11. fastled/filewatcher.py +6 -3
  12. fastled/find_good_connection.py +105 -0
  13. fastled/header_dump.py +63 -0
  14. fastled/install/__init__.py +1 -0
  15. fastled/install/examples_manager.py +62 -0
  16. fastled/install/extension_manager.py +113 -0
  17. fastled/install/main.py +156 -0
  18. fastled/install/project_detection.py +167 -0
  19. fastled/install/test_install.py +373 -0
  20. fastled/install/vscode_config.py +344 -0
  21. fastled/interruptible_http.py +148 -0
  22. fastled/live_client.py +21 -1
  23. fastled/open_browser.py +84 -16
  24. fastled/parse_args.py +110 -9
  25. fastled/playwright/chrome_extension_downloader.py +207 -0
  26. fastled/playwright/playwright_browser.py +773 -0
  27. fastled/playwright/resize_tracking.py +127 -0
  28. fastled/print_filter.py +52 -52
  29. fastled/project_init.py +20 -13
  30. fastled/select_sketch_directory.py +142 -19
  31. fastled/server_flask.py +37 -1
  32. fastled/settings.py +47 -3
  33. fastled/sketch.py +121 -4
  34. fastled/string_diff.py +162 -26
  35. fastled/test/examples.py +7 -5
  36. fastled/types.py +4 -0
  37. fastled/util.py +34 -0
  38. fastled/version.py +41 -41
  39. fastled/web_compile.py +379 -236
  40. fastled/zip_files.py +76 -0
  41. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/METADATA +533 -508
  42. fastled-1.4.50.dist-info/RECORD +60 -0
  43. fastled-1.3.30.dist-info/RECORD +0 -44
  44. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/WHEEL +0 -0
  45. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/entry_points.txt +0 -0
  46. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/licenses/LICENSE +0 -0
  47. {fastled-1.3.30.dist-info → fastled-1.4.50.dist-info}/top_level.txt +0 -0
fastled/client_server.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import shutil
2
+ import sys
2
3
  import tempfile
3
4
  import threading
4
5
  import time
@@ -9,7 +10,9 @@ from pathlib import Path
9
10
 
10
11
  from fastled.compile_server import CompileServer
11
12
  from fastled.docker_manager import DockerManager
13
+ from fastled.emoji_util import EMO
12
14
  from fastled.filewatcher import DebouncedFileWatcherProcess, FileWatcherProcess
15
+ from fastled.find_good_connection import ConnectionResult
13
16
  from fastled.keyboard import SpaceBarWatcher
14
17
  from fastled.open_browser import spawn_http_server
15
18
  from fastled.parse_args import Args
@@ -18,7 +21,6 @@ from fastled.sketch import looks_like_sketch_directory
18
21
  from fastled.types import BuildMode, CompileResult, CompileServerError
19
22
  from fastled.web_compile import (
20
23
  SERVER_PORT,
21
- ConnectionResult,
22
24
  find_good_connection,
23
25
  web_compile,
24
26
  )
@@ -67,7 +69,20 @@ def TEST_BEFORE_COMPILE(url) -> None:
67
69
  def _chunked_print(stdout: str) -> None:
68
70
  lines = stdout.splitlines()
69
71
  for line in lines:
70
- print(line)
72
+ try:
73
+ print(line)
74
+ except UnicodeEncodeError:
75
+ # On Windows, the console may not support Unicode characters
76
+ # Try to encode the line with the console's encoding and replace problematic characters
77
+ try:
78
+ console_encoding = sys.stdout.encoding or "utf-8"
79
+ encoded_line = line.encode(console_encoding, errors="replace").decode(
80
+ console_encoding
81
+ )
82
+ print(encoded_line)
83
+ except Exception:
84
+ # If all else fails, just print the line without problematic characters
85
+ print(line.encode("ascii", errors="replace").decode("ascii"))
71
86
 
72
87
 
73
88
  def _run_web_compiler(
@@ -76,12 +91,26 @@ def _run_web_compiler(
76
91
  build_mode: BuildMode,
77
92
  profile: bool,
78
93
  last_hash_value: str | None,
94
+ no_platformio: bool = False,
95
+ allow_libcompile: bool = False,
79
96
  ) -> CompileResult:
97
+ # Remove the import and libcompile detection logic from here
98
+ # since it will now be passed as a parameter
80
99
  input_dir = Path(directory)
81
100
  output_dir = input_dir / "fastled_js"
101
+
102
+ # Guard: libfastled compilation requires volume source mapping
103
+ if not allow_libcompile:
104
+ print(f"{EMO('⚠️', 'WARNING:')} libfastled compilation disabled.")
105
+
82
106
  start = time.time()
83
107
  web_result = web_compile(
84
- directory=input_dir, host=host, build_mode=build_mode, profile=profile
108
+ directory=input_dir,
109
+ host=host,
110
+ build_mode=build_mode,
111
+ profile=profile,
112
+ no_platformio=no_platformio,
113
+ allow_libcompile=allow_libcompile,
85
114
  )
86
115
  diff = time.time() - start
87
116
  if not web_result.success:
@@ -94,14 +123,59 @@ def _run_web_compiler(
94
123
  (output_dir / "index.html").write_text(error_html, encoding="utf-8")
95
124
  return web_result
96
125
 
126
+ # Extract zip contents to fastled_js directory
127
+ extraction_start_time = time.time()
128
+ output_dir.mkdir(exist_ok=True)
129
+ with tempfile.TemporaryDirectory() as temp_dir:
130
+ temp_path = Path(temp_dir)
131
+ temp_zip = temp_path / "result.zip"
132
+ temp_zip.write_bytes(web_result.zip_bytes)
133
+
134
+ # Clear existing contents
135
+ shutil.rmtree(output_dir, ignore_errors=True)
136
+ output_dir.mkdir(exist_ok=True)
137
+
138
+ # Extract zip contents
139
+ shutil.unpack_archive(temp_zip, output_dir, "zip")
140
+ extraction_time = time.time() - extraction_start_time
141
+
97
142
  def print_results() -> None:
98
143
  hash_value = (
99
144
  web_result.hash_value
100
145
  if web_result.hash_value is not None
101
146
  else "NO HASH VALUE"
102
147
  )
148
+
149
+ # Build timing breakdown
150
+ timing_breakdown = f" Time: {diff:.2f} (seconds)"
151
+ if hasattr(web_result, "zip_time"):
152
+ timing_breakdown += f"\n zip creation: {web_result.zip_time:.2f}"
153
+ if web_result.libfastled_time > 0:
154
+ timing_breakdown += (
155
+ f"\n libfastled: {web_result.libfastled_time:.2f}"
156
+ )
157
+ timing_breakdown += (
158
+ f"\n sketch compile + link: {web_result.sketch_time:.2f}"
159
+ )
160
+ if hasattr(web_result, "response_processing_time"):
161
+ timing_breakdown += f"\n response processing: {web_result.response_processing_time:.2f}"
162
+
163
+ # Calculate any unaccounted time
164
+ accounted_time = (
165
+ web_result.zip_time
166
+ + web_result.libfastled_time
167
+ + web_result.sketch_time
168
+ + web_result.response_processing_time
169
+ + extraction_time
170
+ )
171
+ unaccounted_time = diff - accounted_time
172
+ if extraction_time > 0.01:
173
+ timing_breakdown += f"\n extraction: {extraction_time:.2f}"
174
+ if unaccounted_time > 0.01:
175
+ timing_breakdown += f"\n other overhead: {unaccounted_time:.2f}"
176
+
103
177
  print(
104
- 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"
178
+ f"\nWeb compilation successful\n{timing_breakdown}\n output: {output_dir}\n hash: {hash_value}\n zip size: {len(web_result.zip_bytes)} bytes"
105
179
  )
106
180
 
107
181
  # now check to see if the hash value is the same as the last hash value
@@ -110,27 +184,17 @@ def _run_web_compiler(
110
184
  print_results()
111
185
  return web_result
112
186
 
113
- # Extract zip contents to fastled_js directory
114
- output_dir.mkdir(exist_ok=True)
115
- with tempfile.TemporaryDirectory() as temp_dir:
116
- temp_path = Path(temp_dir)
117
- temp_zip = temp_path / "result.zip"
118
- temp_zip.write_bytes(web_result.zip_bytes)
119
-
120
- # Clear existing contents
121
- shutil.rmtree(output_dir, ignore_errors=True)
122
- output_dir.mkdir(exist_ok=True)
123
-
124
- # Extract zip contents
125
- shutil.unpack_archive(temp_zip, output_dir, "zip")
126
-
127
187
  _chunked_print(web_result.stdout)
128
188
  print_results()
129
189
  return web_result
130
190
 
131
191
 
132
192
  def _try_start_server_or_get_url(
133
- auto_update: bool, args_web: str | bool, localhost: bool, clear: bool
193
+ auto_update: bool,
194
+ args_web: str | bool,
195
+ localhost: bool,
196
+ clear: bool,
197
+ no_platformio: bool = False,
134
198
  ) -> tuple[str, CompileServer | None]:
135
199
  is_local_host = localhost or (
136
200
  isinstance(args_web, str)
@@ -140,9 +204,9 @@ def _try_start_server_or_get_url(
140
204
  local_host_needs_server = False
141
205
  if is_local_host:
142
206
  addr = "localhost" if localhost or not isinstance(args_web, str) else args_web
143
- urls = [addr]
144
207
  if ":" not in addr:
145
- urls.append(f"{addr}:{SERVER_PORT}")
208
+ addr = f"{addr}:{SERVER_PORT}"
209
+ urls = [addr]
146
210
 
147
211
  result: ConnectionResult | None = find_good_connection(urls)
148
212
  if result is not None:
@@ -161,7 +225,9 @@ def _try_start_server_or_get_url(
161
225
  try:
162
226
  print("No local server found, starting one...")
163
227
  compile_server = CompileServer(
164
- auto_updates=auto_update, remove_previous=clear
228
+ auto_updates=auto_update,
229
+ remove_previous=clear,
230
+ no_platformio=no_platformio,
165
231
  )
166
232
  print("Waiting for the local compiler to start...")
167
233
  if not compile_server.ping():
@@ -177,7 +243,9 @@ def _try_start_server_or_get_url(
177
243
  return (DEFAULT_URL, None)
178
244
 
179
245
 
180
- def _try_make_compile_server(clear: bool = False) -> CompileServer | None:
246
+ def _try_make_compile_server(
247
+ clear: bool = False, no_platformio: bool = False
248
+ ) -> CompileServer | None:
181
249
  if not DockerManager.is_docker_installed():
182
250
  return None
183
251
  try:
@@ -189,7 +257,9 @@ def _try_make_compile_server(clear: bool = False) -> CompileServer | None:
189
257
  free_port = find_free_port(start_port=9723, end_port=9743)
190
258
  if free_port is None:
191
259
  return None
192
- compile_server = CompileServer(auto_updates=False, remove_previous=clear)
260
+ compile_server = CompileServer(
261
+ auto_updates=False, remove_previous=clear, no_platformio=no_platformio
262
+ )
193
263
  print("Waiting for the local compiler to start...")
194
264
  if not compile_server.ping():
195
265
  print("Failed to start local compiler.")
@@ -205,14 +275,41 @@ def _try_make_compile_server(clear: bool = False) -> CompileServer | None:
205
275
  return None
206
276
 
207
277
 
208
- def _is_local_host(host: str) -> bool:
278
+ def _background_update_docker_image() -> None:
279
+ """Perform docker image update in the background silently."""
280
+ try:
281
+ # Only attempt update if Docker is installed and running
282
+ if not DockerManager.is_docker_installed():
283
+ return
284
+
285
+ docker_manager = DockerManager()
286
+ docker_running, _ = docker_manager.is_running()
287
+ if not docker_running:
288
+ return
289
+
290
+ # Silently update the docker image
291
+ docker_manager.validate_or_download_image(
292
+ image_name=IMAGE_NAME, tag="latest", upgrade=True
293
+ )
294
+ except KeyboardInterrupt:
295
+ import _thread
296
+
297
+ _thread.interrupt_main()
298
+ except Exception as e:
299
+ # Log warning but don't disrupt user experience
300
+ import warnings
301
+
302
+ warnings.warn(f"Background docker image update failed: {e}")
303
+
304
+
305
+ def _is_local_host(url: str) -> bool:
209
306
  return (
210
- host.startswith("http://localhost")
211
- or host.startswith("http://127.0.0.1")
212
- or host.startswith("http://0.0.0.0")
213
- or host.startswith("http://[::]")
214
- or host.startswith("http://[::1]")
215
- or host.startswith("http://[::ffff:127.0.0.1]")
307
+ url.startswith("http://localhost")
308
+ or url.startswith("http://127.0.0.1")
309
+ or url.startswith("http://0.0.0.0")
310
+ or url.startswith("http://[::]")
311
+ or url.startswith("http://[::1]")
312
+ or url.startswith("http://[::ffff:127.0.0.1]")
216
313
  )
217
314
 
218
315
 
@@ -228,13 +325,19 @@ def run_client(
228
325
  int | None
229
326
  ) = None, # None means auto select a free port, http_port < 0 means no server.
230
327
  clear: bool = False,
328
+ no_platformio: bool = False,
329
+ app: bool = False, # Use app-like browser experience
330
+ background_update: bool = False,
331
+ enable_https: bool = True, # Enable HTTPS for the local server
231
332
  ) -> int:
232
333
  has_checked_newer_version_yet = False
233
334
  compile_server: CompileServer | None = None
234
335
 
235
336
  if host is None:
236
337
  # attempt to start a compile server if docker is installed.
237
- compile_server = _try_make_compile_server(clear=clear)
338
+ compile_server = _try_make_compile_server(
339
+ clear=clear, no_platformio=no_platformio
340
+ )
238
341
  if compile_server is None:
239
342
  host = DEFAULT_URL
240
343
  elif isinstance(host, CompileServer):
@@ -267,6 +370,11 @@ def run_client(
267
370
  # Assume default port for www
268
371
  port = 80
269
372
 
373
+ # Auto-detect libcompile capability on first call
374
+ from fastled.sketch import looks_like_fastled_repo
375
+
376
+ allow_libcompile = is_local_host and looks_like_fastled_repo(Path(".").resolve())
377
+
270
378
  try:
271
379
 
272
380
  def compile_function(
@@ -274,6 +382,8 @@ def run_client(
274
382
  build_mode: BuildMode = build_mode,
275
383
  profile: bool = profile,
276
384
  last_hash_value: str | None = None,
385
+ no_platformio: bool = no_platformio,
386
+ allow_libcompile: bool = allow_libcompile,
277
387
  ) -> CompileResult:
278
388
  TEST_BEFORE_COMPILE(url)
279
389
  return _run_web_compiler(
@@ -282,6 +392,8 @@ def run_client(
282
392
  build_mode=build_mode,
283
393
  profile=profile,
284
394
  last_hash_value=last_hash_value,
395
+ no_platformio=no_platformio,
396
+ allow_libcompile=allow_libcompile,
285
397
  )
286
398
 
287
399
  result: CompileResult = compile_function(last_hash_value=None)
@@ -304,13 +416,18 @@ def run_client(
304
416
  port=http_port,
305
417
  compile_server_port=port,
306
418
  open_browser=open_web_browser,
419
+ app=app,
420
+ enable_https=enable_https,
307
421
  )
308
422
  else:
309
- print("\nCompilation successful.")
423
+ if result.success:
424
+ print("\nCompilation successful.")
425
+ else:
426
+ print("\nCompilation failed.")
310
427
  if compile_server:
311
428
  print("Shutting down compile server...")
312
429
  compile_server.stop()
313
- return 0
430
+ return 0 if result.success else 1
314
431
 
315
432
  if not keep_running or shutdown.is_set():
316
433
  if http_proc:
@@ -337,7 +454,15 @@ def run_client(
337
454
  ) -> tuple[bool, CompileResult]:
338
455
  changed_files = debounced_sketch_watcher.get_all_changes()
339
456
  if changed_files:
340
- print(f"\nChanges detected in {changed_files}")
457
+ # Filter out any fastled_js changes that slipped through
458
+ sketch_changes = [
459
+ f for f in changed_files if "fastled_js" not in Path(f).parts
460
+ ]
461
+ if not sketch_changes:
462
+ # All changes were in fastled_js, ignore them
463
+ return False, last_compiled_result
464
+ print(f"\nChanges detected in {sketch_changes}")
465
+ print("Compiling...")
341
466
  last_hash_value = last_compiled_result.hash_value
342
467
  out = compile_function(last_hash_value=last_hash_value)
343
468
  if not out.success:
@@ -374,9 +499,20 @@ def run_client(
374
499
  )
375
500
  if has_update:
376
501
  print(f"\n🔄 {message}")
377
- print(
378
- "Run with `fastled -u` to update the docker image to the latest version."
379
- )
502
+ if background_update:
503
+ # Start background update in a separate thread
504
+ update_thread = threading.Thread(
505
+ target=_background_update_docker_image, daemon=True
506
+ )
507
+ update_thread.start()
508
+ background_update = False
509
+ else:
510
+ print(
511
+ "Run with `fastled -u` to update the docker image to the latest version."
512
+ )
513
+ print(
514
+ "Or use `--background-update` to update automatically in the background after compilation."
515
+ )
380
516
  except Exception as e:
381
517
  # Don't let Docker check failures interrupt the main flow
382
518
  warnings.warn(f"Failed to check for Docker image updates: {e}")
@@ -408,6 +544,7 @@ def run_client(
408
544
  if changed_files:
409
545
  print(f"\nChanges detected in FastLED source code: {changed_files}")
410
546
  print("Press space bar to trigger compile.")
547
+
411
548
  while True:
412
549
  space_bar_pressed = SpaceBarWatcher.watch_space_bar_pressed(
413
550
  timeout=1.0
@@ -421,6 +558,8 @@ def run_client(
421
558
  print(
422
559
  f"Changes detected in {file_changes}\nHit the space bar to trigger compile."
423
560
  )
561
+ # Re-evaluate libcompile capability when source code changes
562
+ allow_libcompile = True
424
563
 
425
564
  if space_bar_pressed or sketch_files_changed:
426
565
  if space_bar_pressed:
@@ -430,7 +569,10 @@ def run_client(
430
569
  f"Changes detected in {','.join(sketch_files_changed)}, triggering recompile..."
431
570
  )
432
571
  last_compiled_result = compile_function(
433
- last_hash_value=None
572
+ last_hash_value=None, allow_libcompile=allow_libcompile
573
+ )
574
+ allow_libcompile = (
575
+ allow_libcompile and not last_compiled_result.success
434
576
  )
435
577
  print("Finished recompile.")
436
578
  # Drain the space bar queue
@@ -456,6 +598,7 @@ def run_client_server(args: Args) -> int:
456
598
  profile = bool(args.profile)
457
599
  web: str | bool = args.web if isinstance(args.web, str) else bool(args.web)
458
600
  auto_update = bool(args.auto_update)
601
+ background_update = bool(args.background_update)
459
602
  localhost = bool(args.localhost)
460
603
  directory = args.directory if args.directory else Path(".")
461
604
  just_compile = bool(args.just_compile)
@@ -463,6 +606,8 @@ def run_client_server(args: Args) -> int:
463
606
  force_compile = bool(args.force_compile)
464
607
  open_web_browser = not just_compile and not interactive
465
608
  build_mode: BuildMode = BuildMode.from_args(args)
609
+ no_platformio = bool(args.no_platformio)
610
+ app = bool(args.app)
466
611
 
467
612
  if not force_compile and not looks_like_sketch_directory(directory):
468
613
  # if there is only one directory in the sketch directory, use that
@@ -492,7 +637,7 @@ def run_client_server(args: Args) -> int:
492
637
  compile_server: CompileServer | None = None
493
638
  try:
494
639
  url, compile_server = _try_start_server_or_get_url(
495
- auto_update, web, localhost, args.clear
640
+ auto_update, web, localhost, args.clear, no_platformio
496
641
  )
497
642
  except KeyboardInterrupt:
498
643
  print("\nExiting from first try...")
@@ -524,6 +669,9 @@ def run_client_server(args: Args) -> int:
524
669
  build_mode=build_mode,
525
670
  profile=profile,
526
671
  clear=args.clear,
672
+ no_platformio=no_platformio,
673
+ app=app,
674
+ background_update=background_update,
527
675
  )
528
676
  except KeyboardInterrupt:
529
677
  return 1
fastled/compile_server.py CHANGED
@@ -15,6 +15,8 @@ class CompileServer:
15
15
  container_name: str | None = None,
16
16
  platform: Platform = Platform.WASM,
17
17
  remove_previous: bool = False,
18
+ no_platformio: bool = False,
19
+ allow_libcompile: bool = True,
18
20
  ) -> None:
19
21
  from fastled.compile_server_impl import ( # avoid circular import
20
22
  CompileServerImpl,
@@ -29,6 +31,8 @@ class CompileServer:
29
31
  mapped_dir=mapped_dir,
30
32
  auto_start=auto_start,
31
33
  remove_previous=remove_previous,
34
+ no_platformio=no_platformio,
35
+ allow_libcompile=allow_libcompile,
32
36
  )
33
37
 
34
38
  # May throw CompileServerError if server could not be started.
@@ -97,3 +101,9 @@ class CompileServer:
97
101
 
98
102
  def process_running(self) -> bool:
99
103
  return self.impl.process_running()
104
+
105
+ def get_emsdk_headers(self, filepath: Path) -> None:
106
+ """Get EMSDK headers ZIP data from the server and save to filepath."""
107
+ if not str(filepath).endswith(".zip"):
108
+ raise ValueError("Filepath must end with .zip")
109
+ return self.impl.get_emsdk_headers(filepath)
@@ -15,6 +15,7 @@ from fastled.docker_manager import (
15
15
  RunningContainer,
16
16
  Volume,
17
17
  )
18
+ from fastled.emoji_util import EMO
18
19
  from fastled.settings import DEFAULT_CONTAINER_NAME, IMAGE_NAME, SERVER_PORT
19
20
  from fastled.sketch import looks_like_fastled_repo
20
21
  from fastled.types import BuildMode, CompileResult, CompileServerError
@@ -50,6 +51,8 @@ class CompileServerImpl:
50
51
  auto_start: bool = True,
51
52
  container_name: str | None = None,
52
53
  remove_previous: bool = False,
54
+ no_platformio: bool = False,
55
+ allow_libcompile: bool = True,
53
56
  ) -> None:
54
57
  container_name = container_name or DEFAULT_CONTAINER_NAME
55
58
  if interactive and not mapped_dir:
@@ -68,6 +71,18 @@ class CompileServerImpl:
68
71
  self.running_container: RunningContainer | None = None
69
72
  self.auto_updates = auto_updates
70
73
  self.remove_previous = remove_previous
74
+ self.no_platformio = no_platformio
75
+
76
+ # Guard: libfastled compilation requires volume source mapping
77
+ # If we don't have fastled_src_dir (not in FastLED repo), disable libcompile
78
+ if allow_libcompile and self.fastled_src_dir is None:
79
+ print(
80
+ f"{EMO('⚠️', 'WARNING:')} libfastled compilation disabled: volume source mapping not available"
81
+ )
82
+ print(" (not running in FastLED repository)")
83
+ allow_libcompile = False
84
+
85
+ self.allow_libcompile = allow_libcompile
71
86
  self._port = 0 # 0 until compile server is started
72
87
  if auto_start:
73
88
  self.start()
@@ -105,7 +120,12 @@ class CompileServerImpl:
105
120
  if not self.ping():
106
121
  raise RuntimeError("Server is not running")
107
122
  out: CompileResult = web_compile(
108
- directory, host=self.url(), build_mode=build_mode, profile=profile
123
+ directory,
124
+ host=self.url(),
125
+ build_mode=build_mode,
126
+ profile=profile,
127
+ no_platformio=self.no_platformio,
128
+ allow_libcompile=self.allow_libcompile,
109
129
  )
110
130
  return out
111
131
 
@@ -217,6 +237,8 @@ class CompileServerImpl:
217
237
  server_command = ["/bin/bash"]
218
238
  else:
219
239
  server_command = ["python", "/js/run.py", "server"] + SERVER_OPTIONS
240
+ if self.no_platformio:
241
+ server_command.append("--no-platformio")
220
242
  if self.interactive:
221
243
  print("Disabling port forwarding in interactive mode")
222
244
  ports = {}
@@ -316,3 +338,14 @@ class CompileServerImpl:
316
338
  self.docker.suspend_container(self.container_name)
317
339
  self._port = 0
318
340
  print("Compile server stopped")
341
+
342
+ def get_emsdk_headers(self, filepath: Path) -> None:
343
+ """Get EMSDK headers ZIP data from the server and save to filepath."""
344
+ from fastled.util import download_emsdk_headers
345
+
346
+ if not self._port:
347
+ raise RuntimeError("Server has not been started yet")
348
+ if not self.ping():
349
+ raise RuntimeError("Server is not running")
350
+
351
+ download_emsdk_headers(self.url(), filepath)
fastled/docker_manager.py CHANGED
@@ -26,7 +26,6 @@ from docker.models.images import Image
26
26
  from filelock import FileLock
27
27
 
28
28
  from fastled.print_filter import PrintFilter, PrintFilterDefault
29
- from fastled.spinner import Spinner
30
29
 
31
30
  CONFIG_DIR = Path(user_data_dir("fastled", "fastled"))
32
31
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
@@ -252,7 +251,34 @@ class DockerManager:
252
251
  @property
253
252
  def client(self) -> DockerClient:
254
253
  if self._client is None:
255
- self._client = docker.from_env()
254
+ # Retry logic for WSL startup on Windows
255
+ max_retries = 10
256
+ retry_delay = 2 # seconds
257
+ last_error = None
258
+
259
+ for attempt in range(max_retries):
260
+ try:
261
+ self._client = docker.from_env()
262
+ if attempt > 0:
263
+ print(
264
+ f"Successfully connected to Docker after {attempt + 1} attempts"
265
+ )
266
+ return self._client
267
+ except DockerException as e:
268
+ last_error = e
269
+ if attempt < max_retries - 1:
270
+ if attempt == 0:
271
+ print("Waiting for Docker/WSL to be ready...")
272
+ print(
273
+ f"Attempt {attempt + 1}/{max_retries} failed, retrying in {retry_delay}s..."
274
+ )
275
+ time.sleep(retry_delay)
276
+ else:
277
+ print(
278
+ f"Failed to connect to Docker after {max_retries} attempts"
279
+ )
280
+ raise last_error
281
+ assert self._client is not None
256
282
  return self._client
257
283
 
258
284
  @staticmethod
@@ -507,11 +533,9 @@ class DockerManager:
507
533
  return False
508
534
 
509
535
  # Quick check for latest version
510
- with Spinner(f"Pulling newer version of {image_name}:{tag}..."):
511
- cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
512
- cmd_str = subprocess.list2cmdline(cmd_list)
513
- print(f"Running command: {cmd_str}")
514
- subprocess.run(cmd_list, check=True)
536
+ print(f"Pulling newer version of {image_name}:{tag}...")
537
+ cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
538
+ subprocess.run(cmd_list, check=True)
515
539
  print(f"Updated to newer version of {image_name}:{tag}")
516
540
  local_image_hash = self.client.images.get(f"{image_name}:{tag}").id
517
541
  assert local_image_hash is not None
@@ -521,12 +545,10 @@ class DockerManager:
521
545
 
522
546
  except ImageNotFound:
523
547
  print(f"Image {image_name}:{tag} not found.")
524
- with Spinner("Loading "):
525
- # We use docker cli here because it shows the download.
526
- cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
527
- cmd_str = subprocess.list2cmdline(cmd_list)
528
- print(f"Running command: {cmd_str}")
529
- subprocess.run(cmd_list, check=True)
548
+ print("Loading...")
549
+ # We use docker cli here because it shows the download.
550
+ cmd_list = ["docker", "pull", f"{image_name}:{tag}"]
551
+ subprocess.run(cmd_list, check=True)
530
552
  try:
531
553
  local_image = self.client.images.get(f"{image_name}:{tag}")
532
554
  local_image_hash = local_image.id
@@ -980,6 +1002,7 @@ class DockerManager:
980
1002
  def purge(self, image_name: str) -> None:
981
1003
  """
982
1004
  Remove all containers and images associated with the given image name.
1005
+ Also removes FastLED containers by name pattern (including test containers).
983
1006
 
984
1007
  Args:
985
1008
  image_name: The name of the image to purge (without tag)
@@ -990,8 +1013,27 @@ class DockerManager:
990
1013
  try:
991
1014
  containers = self.client.containers.list(all=True)
992
1015
  for container in containers:
1016
+ should_remove = False
1017
+
1018
+ # Check if container uses the specified image
993
1019
  if any(image_name in tag for tag in container.image.tags):
994
- print(f"Removing container {container.name}")
1020
+ should_remove = True
1021
+ print(
1022
+ f"Removing container {container.name} (uses image {image_name})"
1023
+ )
1024
+
1025
+ # Also check for FastLED container name patterns (including test containers)
1026
+ elif any(
1027
+ pattern in container.name
1028
+ for pattern in [
1029
+ "fastled-wasm-container",
1030
+ "fastled-wasm-container-test",
1031
+ ]
1032
+ ):
1033
+ should_remove = True
1034
+ print(f"Removing FastLED container {container.name}")
1035
+
1036
+ if should_remove:
995
1037
  container.remove(force=True)
996
1038
 
997
1039
  except Exception as e:
fastled/emoji_util.py ADDED
@@ -0,0 +1,27 @@
1
+ """
2
+ Emoji utility functions for handling Unicode display issues on Windows cmd.exe
3
+ """
4
+
5
+ import sys
6
+
7
+
8
+ def EMO(emoji: str, fallback: str) -> str:
9
+ """Get emoji with fallback for systems that don't support Unicode properly"""
10
+ try:
11
+ # Test if we can encode the emoji properly
12
+ emoji.encode(sys.stdout.encoding or "utf-8")
13
+ return emoji
14
+ except (UnicodeEncodeError, AttributeError):
15
+ return fallback
16
+
17
+
18
+ def safe_print(text: str) -> None:
19
+ """Print text safely, handling Unicode/emoji encoding issues on Windows cmd.exe"""
20
+ try:
21
+ print(text)
22
+ except UnicodeEncodeError:
23
+ # Replace problematic characters with safe alternatives
24
+ safe_text = text.encode(
25
+ sys.stdout.encoding or "utf-8", errors="replace"
26
+ ).decode(sys.stdout.encoding or "utf-8")
27
+ print(safe_text)