fastled 1.4.2__py3-none-any.whl → 1.4.4__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/__init__.py CHANGED
@@ -41,18 +41,29 @@ class Api:
41
41
  profile: bool = False, # When true then profile information will be enabled and included in the zip.
42
42
  no_platformio: bool = False,
43
43
  ) -> CompileResult:
44
+ from fastled.sketch import looks_like_fastled_repo
44
45
  from fastled.web_compile import web_compile
45
46
 
46
47
  if isinstance(host, CompileServer):
47
48
  host = host.url()
48
49
  if isinstance(directory, str):
49
50
  directory = Path(directory)
51
+
52
+ # Guard: libfastled compilation requires volume source mapping
53
+ # Only allow libcompile if we're in a FastLED repository
54
+ allow_libcompile = looks_like_fastled_repo(Path(".").resolve())
55
+ if not allow_libcompile:
56
+ print(
57
+ "⚠️ libfastled compilation disabled: not running in FastLED repository"
58
+ )
59
+
50
60
  out: CompileResult = web_compile(
51
61
  directory,
52
62
  host,
53
63
  build_mode=build_mode,
54
64
  profile=profile,
55
65
  no_platformio=no_platformio,
66
+ allow_libcompile=allow_libcompile,
56
67
  )
57
68
  return out
58
69
 
fastled/__version__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  # IMPORTANT! There's a bug in github which will REJECT any version update
2
2
  # that has any other change in the repo. Please bump the version as the
3
3
  # ONLY change in a commit, or else the pypi update and the release will fail.
4
- __version__ = "1.4.2"
4
+ __version__ = "1.4.4"
5
5
 
6
6
  __version_url_latest__ = "https://raw.githubusercontent.com/zackees/fastled-wasm/refs/heads/main/src/fastled/__version__.py"
fastled/client_server.py CHANGED
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  from fastled.compile_server import CompileServer
12
12
  from fastled.docker_manager import DockerManager
13
13
  from fastled.filewatcher import DebouncedFileWatcherProcess, FileWatcherProcess
14
+ from fastled.find_good_connection import ConnectionResult
14
15
  from fastled.keyboard import SpaceBarWatcher
15
16
  from fastled.open_browser import spawn_http_server
16
17
  from fastled.parse_args import Args
@@ -19,7 +20,6 @@ from fastled.sketch import looks_like_sketch_directory
19
20
  from fastled.types import BuildMode, CompileResult, CompileServerError
20
21
  from fastled.web_compile import (
21
22
  SERVER_PORT,
22
- ConnectionResult,
23
23
  find_good_connection,
24
24
  web_compile,
25
25
  )
@@ -101,9 +101,17 @@ def _run_web_compiler(
101
101
  profile: bool,
102
102
  last_hash_value: str | None,
103
103
  no_platformio: bool = False,
104
+ allow_libcompile: bool = False,
104
105
  ) -> CompileResult:
106
+ # Remove the import and libcompile detection logic from here
107
+ # since it will now be passed as a parameter
105
108
  input_dir = Path(directory)
106
109
  output_dir = input_dir / "fastled_js"
110
+
111
+ # Guard: libfastled compilation requires volume source mapping
112
+ if not allow_libcompile:
113
+ print("⚠️ libfastled compilation disabled: not running in FastLED repository")
114
+
107
115
  start = time.time()
108
116
  web_result = web_compile(
109
117
  directory=input_dir,
@@ -111,6 +119,7 @@ def _run_web_compiler(
111
119
  build_mode=build_mode,
112
120
  profile=profile,
113
121
  no_platformio=no_platformio,
122
+ allow_libcompile=allow_libcompile,
114
123
  )
115
124
  diff = time.time() - start
116
125
  if not web_result.success:
@@ -310,6 +319,11 @@ def run_client(
310
319
  # Assume default port for www
311
320
  port = 80
312
321
 
322
+ # Auto-detect libcompile capability on first call
323
+ from fastled.sketch import looks_like_fastled_repo
324
+
325
+ allow_libcompile = looks_like_fastled_repo(Path(".").resolve())
326
+
313
327
  try:
314
328
 
315
329
  def compile_function(
@@ -318,6 +332,7 @@ def run_client(
318
332
  profile: bool = profile,
319
333
  last_hash_value: str | None = None,
320
334
  no_platformio: bool = no_platformio,
335
+ allow_libcompile: bool = allow_libcompile,
321
336
  ) -> CompileResult:
322
337
  TEST_BEFORE_COMPILE(url)
323
338
  return _run_web_compiler(
@@ -327,6 +342,7 @@ def run_client(
327
342
  profile=profile,
328
343
  last_hash_value=last_hash_value,
329
344
  no_platformio=no_platformio,
345
+ allow_libcompile=allow_libcompile,
330
346
  )
331
347
 
332
348
  result: CompileResult = compile_function(last_hash_value=None)
@@ -454,6 +470,10 @@ def run_client(
454
470
  if changed_files:
455
471
  print(f"\nChanges detected in FastLED source code: {changed_files}")
456
472
  print("Press space bar to trigger compile.")
473
+
474
+ # Re-evaluate libcompile capability when source code changes
475
+ allow_libcompile = True
476
+
457
477
  while True:
458
478
  space_bar_pressed = SpaceBarWatcher.watch_space_bar_pressed(
459
479
  timeout=1.0
@@ -476,8 +496,9 @@ def run_client(
476
496
  f"Changes detected in {','.join(sketch_files_changed)}, triggering recompile..."
477
497
  )
478
498
  last_compiled_result = compile_function(
479
- last_hash_value=None
499
+ last_hash_value=None, allow_libcompile=allow_libcompile
480
500
  )
501
+ allow_libcompile = False
481
502
  print("Finished recompile.")
482
503
  # Drain the space bar queue
483
504
  SpaceBarWatcher.watch_space_bar_pressed()
fastled/compile_server.py CHANGED
@@ -16,6 +16,7 @@ class CompileServer:
16
16
  platform: Platform = Platform.WASM,
17
17
  remove_previous: bool = False,
18
18
  no_platformio: bool = False,
19
+ allow_libcompile: bool = True,
19
20
  ) -> None:
20
21
  from fastled.compile_server_impl import ( # avoid circular import
21
22
  CompileServerImpl,
@@ -31,6 +32,7 @@ class CompileServer:
31
32
  auto_start=auto_start,
32
33
  remove_previous=remove_previous,
33
34
  no_platformio=no_platformio,
35
+ allow_libcompile=allow_libcompile,
34
36
  )
35
37
 
36
38
  # May throw CompileServerError if server could not be started.
@@ -51,6 +51,7 @@ class CompileServerImpl:
51
51
  container_name: str | None = None,
52
52
  remove_previous: bool = False,
53
53
  no_platformio: bool = False,
54
+ allow_libcompile: bool = True,
54
55
  ) -> None:
55
56
  container_name = container_name or DEFAULT_CONTAINER_NAME
56
57
  if interactive and not mapped_dir:
@@ -70,6 +71,17 @@ class CompileServerImpl:
70
71
  self.auto_updates = auto_updates
71
72
  self.remove_previous = remove_previous
72
73
  self.no_platformio = no_platformio
74
+
75
+ # Guard: libfastled compilation requires volume source mapping
76
+ # If we don't have fastled_src_dir (not in FastLED repo), disable libcompile
77
+ if allow_libcompile and self.fastled_src_dir is None:
78
+ print(
79
+ "⚠️ libfastled compilation disabled: volume source mapping not available"
80
+ )
81
+ print(" (not running in FastLED repository)")
82
+ allow_libcompile = False
83
+
84
+ self.allow_libcompile = allow_libcompile
73
85
  self._port = 0 # 0 until compile server is started
74
86
  if auto_start:
75
87
  self.start()
@@ -112,6 +124,7 @@ class CompileServerImpl:
112
124
  build_mode=build_mode,
113
125
  profile=profile,
114
126
  no_platformio=self.no_platformio,
127
+ allow_libcompile=self.allow_libcompile,
115
128
  )
116
129
  return out
117
130
 
@@ -0,0 +1,105 @@
1
+ import _thread
2
+ import time
3
+ from concurrent.futures import Future, ThreadPoolExecutor, as_completed
4
+ from dataclasses import dataclass
5
+ from typing import Dict, Tuple
6
+
7
+ import httpx
8
+
9
+ _TIMEOUT = 30.0
10
+
11
+ _EXECUTOR = ThreadPoolExecutor(max_workers=8)
12
+
13
+ # In-memory cache for connection results
14
+ # Key: (tuple of urls, filter_out_bad, use_ipv6)
15
+ # Value: (ConnectionResult | None, timestamp)
16
+ _CONNECTION_CACHE: Dict[
17
+ Tuple[tuple, bool, bool], Tuple["ConnectionResult | None", float]
18
+ ] = {}
19
+ _CACHE_TTL = 60.0 * 60.0 # Cache results for 1 hour
20
+
21
+
22
+ @dataclass
23
+ class ConnectionResult:
24
+ host: str
25
+ success: bool
26
+ ipv4: bool
27
+
28
+
29
+ def _sanitize_host(host: str) -> str:
30
+ if host.startswith("http"):
31
+ return host
32
+ is_local_host = "localhost" in host or "127.0.0.1" in host or "0.0.0.0" in host
33
+ use_https = not is_local_host
34
+ if use_https:
35
+ return host if host.startswith("https://") else f"https://{host}"
36
+ return host if host.startswith("http://") else f"http://{host}"
37
+
38
+
39
+ def _test_connection(host: str, use_ipv4: bool) -> ConnectionResult:
40
+ # Function static cache
41
+ host = _sanitize_host(host)
42
+ transport = httpx.HTTPTransport(local_address="0.0.0.0") if use_ipv4 else None
43
+ result: ConnectionResult | None = None
44
+ try:
45
+ with httpx.Client(
46
+ timeout=_TIMEOUT,
47
+ transport=transport,
48
+ ) as test_client:
49
+ test_response = test_client.get(
50
+ f"{host}/healthz", timeout=3, follow_redirects=True
51
+ )
52
+ result = ConnectionResult(host, test_response.status_code == 200, use_ipv4)
53
+ except KeyboardInterrupt:
54
+ _thread.interrupt_main()
55
+ result = ConnectionResult(host, False, use_ipv4)
56
+ except TimeoutError:
57
+ result = ConnectionResult(host, False, use_ipv4)
58
+ except Exception:
59
+ result = ConnectionResult(host, False, use_ipv4)
60
+ return result
61
+
62
+
63
+ def find_good_connection(
64
+ urls: list[str], filter_out_bad=True, use_ipv6: bool = True
65
+ ) -> ConnectionResult | None:
66
+ # Create cache key from parameters
67
+ cache_key = (tuple(sorted(urls)), filter_out_bad, use_ipv6)
68
+ current_time = time.time()
69
+
70
+ # Check if we have a cached result
71
+ if cache_key in _CONNECTION_CACHE:
72
+ cached_result, cached_time = _CONNECTION_CACHE[cache_key]
73
+ if current_time - cached_time < _CACHE_TTL:
74
+ return cached_result
75
+ else:
76
+ # Remove expired cache entry
77
+ del _CONNECTION_CACHE[cache_key]
78
+
79
+ # No valid cache entry, perform the actual connection test
80
+ futures: list[Future] = []
81
+ for url in urls:
82
+
83
+ f = _EXECUTOR.submit(_test_connection, url, use_ipv4=True)
84
+ futures.append(f)
85
+ if use_ipv6 and "localhost" not in url:
86
+ f_v6 = _EXECUTOR.submit(_test_connection, url, use_ipv4=False)
87
+ futures.append(f_v6)
88
+
89
+ result = None
90
+ try:
91
+ # Return first successful result
92
+ for future in as_completed(futures):
93
+ connection_result: ConnectionResult = future.result()
94
+ if connection_result.success or not filter_out_bad:
95
+ result = connection_result
96
+ break
97
+ finally:
98
+ # Cancel any remaining futures
99
+ for future in futures:
100
+ future.cancel()
101
+
102
+ # Cache the result (even if None)
103
+ _CONNECTION_CACHE[cache_key] = (result, current_time)
104
+
105
+ return result
@@ -79,8 +79,83 @@ class PlaywrightBrowser:
79
79
  )
80
80
 
81
81
  if self.page is None and self.browser is not None:
82
- # Create a new browser context and page
83
- context = await self.browser.new_context()
82
+ # Detect system device scale factor to match normal browser behavior
83
+ import platform
84
+
85
+ device_scale_factor = 1.0
86
+
87
+ # Try to detect system display scaling
88
+ try:
89
+ if platform.system() == "Windows":
90
+ # On Windows, try to get the DPI scaling factor
91
+ import ctypes
92
+
93
+ try:
94
+ # Get DPI awareness and scale factor
95
+ user32 = ctypes.windll.user32
96
+ user32.SetProcessDPIAware()
97
+ dc = user32.GetDC(0)
98
+ dpi = ctypes.windll.gdi32.GetDeviceCaps(dc, 88) # LOGPIXELSX
99
+ user32.ReleaseDC(0, dc)
100
+ device_scale_factor = dpi / 96.0 # 96 DPI is 100% scaling
101
+ except Exception:
102
+ # Fallback: try alternative method
103
+ try:
104
+ import tkinter as tk
105
+
106
+ root = tk.Tk()
107
+ device_scale_factor = root.winfo_fpixels("1i") / 96.0
108
+ root.destroy()
109
+ except Exception:
110
+ pass
111
+ elif platform.system() == "Darwin": # macOS
112
+ # On macOS, try to get the display scaling
113
+ try:
114
+ import subprocess
115
+
116
+ result = subprocess.run(
117
+ ["system_profiler", "SPDisplaysDataType"],
118
+ capture_output=True,
119
+ text=True,
120
+ timeout=5,
121
+ )
122
+ if "Retina" in result.stdout or "2x" in result.stdout:
123
+ device_scale_factor = 2.0
124
+ elif "3x" in result.stdout:
125
+ device_scale_factor = 3.0
126
+ except Exception:
127
+ pass
128
+ elif platform.system() == "Linux":
129
+ # On Linux, try to get display scaling from environment or system
130
+ try:
131
+ import os
132
+
133
+ # Try GDK scaling first
134
+ gdk_scale = os.environ.get("GDK_SCALE")
135
+ if gdk_scale:
136
+ device_scale_factor = float(gdk_scale)
137
+ else:
138
+ # Try QT scaling
139
+ qt_scale = os.environ.get("QT_SCALE_FACTOR")
140
+ if qt_scale:
141
+ device_scale_factor = float(qt_scale)
142
+ except Exception:
143
+ pass
144
+ except Exception:
145
+ # If all detection methods fail, default to 1.0
146
+ device_scale_factor = 1.0
147
+
148
+ # Ensure device scale factor is reasonable (between 0.5 and 4.0)
149
+ device_scale_factor = max(0.5, min(4.0, device_scale_factor))
150
+
151
+ print(f"[PYTHON] Detected device scale factor: {device_scale_factor}")
152
+
153
+ # Create a new browser context with proper device scale factor
154
+ context = await self.browser.new_context(
155
+ device_scale_factor=device_scale_factor,
156
+ # Also ensure viewport scaling is handled properly
157
+ viewport={"width": 1280, "height": 720} if not self.headless else None,
158
+ )
84
159
  self.page = await context.new_page()
85
160
 
86
161
  async def open_url(self, url: str) -> None:
fastled/web_compile.py CHANGED
@@ -1,35 +1,22 @@
1
- import _thread
2
1
  import io
3
- import json
4
2
  import os
5
3
  import shutil
6
4
  import tempfile
7
5
  import time
8
6
  import zipfile
9
- from concurrent.futures import Future, ThreadPoolExecutor, as_completed
10
- from dataclasses import dataclass
11
7
  from pathlib import Path
12
8
 
13
9
  import httpx
14
10
 
11
+ from fastled.find_good_connection import find_good_connection
15
12
  from fastled.settings import SERVER_PORT
16
- from fastled.sketch import get_sketch_files
17
13
  from fastled.types import BuildMode, CompileResult
18
- from fastled.util import hash_file
14
+ from fastled.zip_files import ZipResult, zip_files
19
15
 
20
16
  DEFAULT_HOST = "https://fastled.onrender.com"
21
17
  ENDPOINT_COMPILED_WASM = "compile/wasm"
22
18
  _TIMEOUT = 60 * 4 # 2 mins timeout
23
19
  _AUTH_TOKEN = "oBOT5jbsO4ztgrpNsQwlmFLIKB"
24
- ENABLE_EMBEDDED_DATA = True
25
- _EXECUTOR = ThreadPoolExecutor(max_workers=8)
26
-
27
-
28
- @dataclass
29
- class ConnectionResult:
30
- host: str
31
- success: bool
32
- ipv4: bool
33
20
 
34
21
 
35
22
  def _sanitize_host(host: str) -> str:
@@ -42,122 +29,6 @@ def _sanitize_host(host: str) -> str:
42
29
  return host if host.startswith("http://") else f"http://{host}"
43
30
 
44
31
 
45
- def _test_connection(host: str, use_ipv4: bool) -> ConnectionResult:
46
- # Function static cache
47
- host = _sanitize_host(host)
48
- transport = httpx.HTTPTransport(local_address="0.0.0.0") if use_ipv4 else None
49
- result: ConnectionResult | None = None
50
- try:
51
- with httpx.Client(
52
- timeout=_TIMEOUT,
53
- transport=transport,
54
- ) as test_client:
55
- test_response = test_client.get(
56
- f"{host}/healthz", timeout=3, follow_redirects=True
57
- )
58
- result = ConnectionResult(host, test_response.status_code == 200, use_ipv4)
59
- except KeyboardInterrupt:
60
- _thread.interrupt_main()
61
- result = ConnectionResult(host, False, use_ipv4)
62
- except TimeoutError:
63
- result = ConnectionResult(host, False, use_ipv4)
64
- except Exception:
65
- result = ConnectionResult(host, False, use_ipv4)
66
- return result
67
-
68
-
69
- def _file_info(file_path: Path) -> str:
70
- hash_txt = hash_file(file_path)
71
- file_size = file_path.stat().st_size
72
- json_str = json.dumps({"hash": hash_txt, "size": file_size})
73
- return json_str
74
-
75
-
76
- @dataclass
77
- class ZipResult:
78
- zip_bytes: bytes
79
- zip_embedded_bytes: bytes | None
80
- success: bool
81
- error: str | None
82
-
83
-
84
- def zip_files(directory: Path, build_mode: BuildMode) -> ZipResult | Exception:
85
- print("Zipping files...")
86
- try:
87
- files = get_sketch_files(directory)
88
- if not files:
89
- raise FileNotFoundError(f"No files found in {directory}")
90
- for f in files:
91
- print(f"Adding file: {f}")
92
- # Create in-memory zip file
93
- has_embedded_zip = False
94
- zip_embedded_buffer = io.BytesIO()
95
- zip_buffer = io.BytesIO()
96
- with zipfile.ZipFile(
97
- zip_embedded_buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9
98
- ) as emebedded_zip_file:
99
- with zipfile.ZipFile(
100
- zip_buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9
101
- ) as zip_file:
102
- for file_path in files:
103
- if "fastled_js" in str(file_path):
104
- # These can be huge, don't send the output files back to the server!
105
- continue
106
- relative_path = file_path.relative_to(directory)
107
- achive_path = str(Path("wasm") / relative_path)
108
- if str(relative_path).startswith("data") and ENABLE_EMBEDDED_DATA:
109
- _file_info_str = _file_info(file_path)
110
- zip_file.writestr(
111
- achive_path + ".embedded.json", _file_info_str
112
- )
113
- emebedded_zip_file.write(file_path, relative_path)
114
- has_embedded_zip = True
115
- else:
116
- zip_file.write(file_path, achive_path)
117
- # write build mode into the file as build.txt so that sketches are fingerprinted
118
- # based on the build mode. Otherwise the same sketch with different build modes
119
- # will have the same fingerprint.
120
- zip_file.writestr(
121
- str(Path("wasm") / "build_mode.txt"), build_mode.value
122
- )
123
- result = ZipResult(
124
- zip_bytes=zip_buffer.getvalue(),
125
- zip_embedded_bytes=(
126
- zip_embedded_buffer.getvalue() if has_embedded_zip else None
127
- ),
128
- success=True,
129
- error=None,
130
- )
131
- return result
132
- except Exception as e:
133
- return e
134
-
135
-
136
- def find_good_connection(
137
- urls: list[str], filter_out_bad=True, use_ipv6: bool = True
138
- ) -> ConnectionResult | None:
139
- futures: list[Future] = []
140
- for url in urls:
141
-
142
- f = _EXECUTOR.submit(_test_connection, url, use_ipv4=True)
143
- futures.append(f)
144
- if use_ipv6 and "localhost" not in url:
145
- f_v6 = _EXECUTOR.submit(_test_connection, url, use_ipv4=False)
146
- futures.append(f_v6)
147
-
148
- try:
149
- # Return first successful result
150
- for future in as_completed(futures):
151
- result: ConnectionResult = future.result()
152
- if result.success or not filter_out_bad:
153
- return result
154
- finally:
155
- # Cancel any remaining futures
156
- for future in futures:
157
- future.cancel()
158
- return None
159
-
160
-
161
32
  def _banner(msg: str) -> str:
162
33
  """
163
34
  Create a banner for the given message.
@@ -189,6 +60,179 @@ def _print_banner(msg: str) -> None:
189
60
  print(_banner(msg))
190
61
 
191
62
 
63
+ def _compile_libfastled(
64
+ host: str,
65
+ auth_token: str,
66
+ build_mode: BuildMode,
67
+ ) -> httpx.Response:
68
+ """Compile the FastLED library separately."""
69
+ host = _sanitize_host(host)
70
+ urls = [host]
71
+ domain = host.split("://")[-1]
72
+ if ":" not in domain:
73
+ urls.append(f"{host}:{SERVER_PORT}")
74
+
75
+ connection_result = find_good_connection(urls)
76
+ if connection_result is None:
77
+ raise ConnectionError(
78
+ "Connection failed to all endpoints for libfastled compilation"
79
+ )
80
+
81
+ ipv4_stmt = "IPv4" if connection_result.ipv4 else "IPv6"
82
+ transport = (
83
+ httpx.HTTPTransport(local_address="0.0.0.0") if connection_result.ipv4 else None
84
+ )
85
+
86
+ with httpx.Client(
87
+ transport=transport,
88
+ timeout=_TIMEOUT * 2, # Give more time for library compilation
89
+ ) as client:
90
+ headers = {
91
+ "accept": "application/json",
92
+ "authorization": auth_token,
93
+ "build": build_mode.value.lower(),
94
+ }
95
+
96
+ url = f"{connection_result.host}/compile/libfastled"
97
+ print(f"Compiling libfastled on {url} via {ipv4_stmt}")
98
+ response = client.post(
99
+ url,
100
+ headers=headers,
101
+ timeout=_TIMEOUT * 2,
102
+ )
103
+
104
+ return response
105
+
106
+
107
+ def _send_compile_request(
108
+ host: str,
109
+ zip_bytes: bytes,
110
+ auth_token: str,
111
+ build_mode: BuildMode,
112
+ profile: bool,
113
+ no_platformio: bool,
114
+ allow_libcompile: bool,
115
+ ) -> httpx.Response:
116
+ """Send the compile request to the server and return the response."""
117
+ host = _sanitize_host(host)
118
+ urls = [host]
119
+ domain = host.split("://")[-1]
120
+ if ":" not in domain:
121
+ urls.append(f"{host}:{SERVER_PORT}")
122
+
123
+ connection_result = find_good_connection(urls)
124
+ if connection_result is None:
125
+ raise ConnectionError("Connection failed to all endpoints")
126
+
127
+ ipv4_stmt = "IPv4" if connection_result.ipv4 else "IPv6"
128
+ transport = (
129
+ httpx.HTTPTransport(local_address="0.0.0.0") if connection_result.ipv4 else None
130
+ )
131
+
132
+ archive_size = len(zip_bytes)
133
+
134
+ with httpx.Client(
135
+ transport=transport,
136
+ timeout=_TIMEOUT,
137
+ ) as client:
138
+ headers = {
139
+ "accept": "application/json",
140
+ "authorization": auth_token,
141
+ "build": (
142
+ build_mode.value.lower()
143
+ if build_mode
144
+ else BuildMode.QUICK.value.lower()
145
+ ),
146
+ "profile": "true" if profile else "false",
147
+ "no-platformio": "true" if no_platformio else "false",
148
+ "allow-libcompile": "false", # Always false since we handle it manually
149
+ }
150
+
151
+ url = f"{connection_result.host}/{ENDPOINT_COMPILED_WASM}"
152
+ print(
153
+ f"Compiling sketch on {url} via {ipv4_stmt}. Zip size: {archive_size} bytes"
154
+ )
155
+ files = {"file": ("wasm.zip", zip_bytes, "application/x-zip-compressed")}
156
+ response = client.post(
157
+ url,
158
+ follow_redirects=True,
159
+ files=files,
160
+ headers=headers,
161
+ timeout=_TIMEOUT,
162
+ )
163
+
164
+ return response
165
+
166
+
167
+ def _process_compile_response(
168
+ response: httpx.Response,
169
+ zip_result: ZipResult,
170
+ start_time: float,
171
+ ) -> CompileResult:
172
+ """Process the compile response and return the final result."""
173
+ if response.status_code != 200:
174
+ json_response = response.json()
175
+ detail = json_response.get("detail", "Could not compile")
176
+ return CompileResult(
177
+ success=False, stdout=detail, hash_value=None, zip_bytes=b""
178
+ )
179
+
180
+ print(f"Response status code: {response}")
181
+ # Create a temporary directory to extract the zip
182
+ with tempfile.TemporaryDirectory() as extract_dir:
183
+ extract_path = Path(extract_dir)
184
+
185
+ # Write the response content to a temporary zip file
186
+ temp_zip = extract_path / "response.zip"
187
+ temp_zip.write_bytes(response.content)
188
+
189
+ # Extract the zip
190
+ shutil.unpack_archive(temp_zip, extract_path, "zip")
191
+
192
+ if zip_result.zip_embedded_bytes:
193
+ # extract the embedded bytes, which were not sent to the server
194
+ temp_zip.write_bytes(zip_result.zip_embedded_bytes)
195
+ shutil.unpack_archive(temp_zip, extract_path, "zip")
196
+
197
+ # we don't need the temp zip anymore
198
+ temp_zip.unlink()
199
+
200
+ # Read stdout from out.txt if it exists
201
+ stdout_file = extract_path / "out.txt"
202
+ hash_file = extract_path / "hash.txt"
203
+ stdout = (
204
+ stdout_file.read_text(encoding="utf-8", errors="replace")
205
+ if stdout_file.exists()
206
+ else ""
207
+ )
208
+ hash_value = (
209
+ hash_file.read_text(encoding="utf-8", errors="replace")
210
+ if hash_file.exists()
211
+ else None
212
+ )
213
+
214
+ # now rezip the extracted files since we added the embedded json files
215
+ out_buffer = io.BytesIO()
216
+ with zipfile.ZipFile(
217
+ out_buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9
218
+ ) as out_zip:
219
+ for root, _, _files in os.walk(extract_path):
220
+ for file in _files:
221
+ file_path = Path(root) / file
222
+ relative_path = file_path.relative_to(extract_path)
223
+ out_zip.write(file_path, relative_path)
224
+
225
+ diff_time = time.time() - start_time
226
+ msg = f"Compilation success, took {diff_time:.2f} seconds"
227
+ _print_banner(msg)
228
+ return CompileResult(
229
+ success=True,
230
+ stdout=stdout,
231
+ hash_value=hash_value,
232
+ zip_bytes=out_buffer.getvalue(),
233
+ )
234
+
235
+
192
236
  def web_compile(
193
237
  directory: Path | str,
194
238
  host: str | None = None,
@@ -196,6 +240,7 @@ def web_compile(
196
240
  build_mode: BuildMode | None = None,
197
241
  profile: bool = False,
198
242
  no_platformio: bool = False,
243
+ allow_libcompile: bool = True,
199
244
  ) -> CompileResult:
200
245
  start_time = time.time()
201
246
  if isinstance(directory, str):
@@ -206,125 +251,54 @@ def web_compile(
206
251
  auth_token = auth_token or _AUTH_TOKEN
207
252
  if not directory.exists():
208
253
  raise FileNotFoundError(f"Directory not found: {directory}")
209
- zip_result = zip_files(directory, build_mode=build_mode)
254
+ zip_result: ZipResult | Exception = zip_files(directory, build_mode=build_mode)
210
255
  if isinstance(zip_result, Exception):
211
256
  return CompileResult(
212
257
  success=False, stdout=str(zip_result), hash_value=None, zip_bytes=b""
213
258
  )
214
259
  zip_bytes = zip_result.zip_bytes
215
- archive_size = len(zip_bytes)
216
260
  print(f"Web compiling on {host}...")
217
261
  try:
218
- host = _sanitize_host(host)
219
- urls = [host]
220
- domain = host.split("://")[-1]
221
- if ":" not in domain:
222
- urls.append(f"{host}:{SERVER_PORT}")
223
-
224
- connection_result = find_good_connection(urls)
225
- if connection_result is None:
226
- _print_banner("Connection failed to all endpoints")
227
- return CompileResult(
228
- success=False,
229
- stdout="Connection failed",
230
- hash_value=None,
231
- zip_bytes=b"",
232
- )
233
-
234
- ipv4_stmt = "IPv4" if connection_result.ipv4 else "IPv6"
235
- transport = (
236
- httpx.HTTPTransport(local_address="0.0.0.0")
237
- if connection_result.ipv4
238
- else None
262
+ # Step 1: Compile libfastled if requested
263
+ if allow_libcompile:
264
+ print("Step 1: Compiling libfastled...")
265
+ try:
266
+ libfastled_response = _compile_libfastled(host, auth_token, build_mode)
267
+ if libfastled_response.status_code != 200:
268
+ print(
269
+ f"Warning: libfastled compilation failed with status {libfastled_response.status_code}"
270
+ )
271
+ # Continue with sketch compilation even if libfastled fails
272
+ else:
273
+ print(" libfastled compilation successful")
274
+ except Exception as e:
275
+ print(f"Warning: libfastled compilation failed: {e}")
276
+ # Continue with sketch compilation even if libfastled fails
277
+ else:
278
+ print("Step 1 (skipped): Compiling libfastled")
279
+
280
+ # Step 2: Compile the sketch
281
+ print("Step 2: Compiling sketch...")
282
+ response = _send_compile_request(
283
+ host,
284
+ zip_bytes,
285
+ auth_token,
286
+ build_mode,
287
+ profile,
288
+ no_platformio,
289
+ False, # allow_libcompile is always False since we handle it manually
290
+ )
291
+
292
+ return _process_compile_response(response, zip_result, start_time)
293
+
294
+ except ConnectionError as e:
295
+ _print_banner(str(e))
296
+ return CompileResult(
297
+ success=False,
298
+ stdout=str(e),
299
+ hash_value=None,
300
+ zip_bytes=b"",
239
301
  )
240
- with httpx.Client(
241
- transport=transport,
242
- timeout=_TIMEOUT,
243
- ) as client:
244
- headers = {
245
- "accept": "application/json",
246
- "authorization": auth_token,
247
- "build": (
248
- build_mode.value.lower()
249
- if build_mode
250
- else BuildMode.QUICK.value.lower()
251
- ),
252
- "profile": "true" if profile else "false",
253
- "no-platformio": "true" if no_platformio else "false",
254
- }
255
-
256
- url = f"{connection_result.host}/{ENDPOINT_COMPILED_WASM}"
257
- print(f"Compiling on {url} via {ipv4_stmt}. Zip size: {archive_size} bytes")
258
- files = {"file": ("wasm.zip", zip_bytes, "application/x-zip-compressed")}
259
- response = client.post(
260
- url,
261
- follow_redirects=True,
262
- files=files,
263
- headers=headers,
264
- timeout=_TIMEOUT,
265
- )
266
-
267
- if response.status_code != 200:
268
- json_response = response.json()
269
- detail = json_response.get("detail", "Could not compile")
270
- return CompileResult(
271
- success=False, stdout=detail, hash_value=None, zip_bytes=b""
272
- )
273
-
274
- print(f"Response status code: {response}")
275
- # Create a temporary directory to extract the zip
276
- with tempfile.TemporaryDirectory() as extract_dir:
277
- extract_path = Path(extract_dir)
278
-
279
- # Write the response content to a temporary zip file
280
- temp_zip = extract_path / "response.zip"
281
- temp_zip.write_bytes(response.content)
282
-
283
- # Extract the zip
284
- shutil.unpack_archive(temp_zip, extract_path, "zip")
285
-
286
- if zip_result.zip_embedded_bytes:
287
- # extract the embedded bytes, which were not sent to the server
288
- temp_zip.write_bytes(zip_result.zip_embedded_bytes)
289
- shutil.unpack_archive(temp_zip, extract_path, "zip")
290
-
291
- # we don't need the temp zip anymore
292
- temp_zip.unlink()
293
-
294
- # Read stdout from out.txt if it exists
295
- stdout_file = extract_path / "out.txt"
296
- hash_file = extract_path / "hash.txt"
297
- stdout = (
298
- stdout_file.read_text(encoding="utf-8", errors="replace")
299
- if stdout_file.exists()
300
- else ""
301
- )
302
- hash_value = (
303
- hash_file.read_text(encoding="utf-8", errors="replace")
304
- if hash_file.exists()
305
- else None
306
- )
307
-
308
- # now rezip the extracted files since we added the embedded json files
309
- out_buffer = io.BytesIO()
310
- with zipfile.ZipFile(
311
- out_buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9
312
- ) as out_zip:
313
- for root, _, _files in os.walk(extract_path):
314
- for file in _files:
315
- file_path = Path(root) / file
316
- relative_path = file_path.relative_to(extract_path)
317
- out_zip.write(file_path, relative_path)
318
-
319
- diff_time = time.time() - start_time
320
- msg = f"Compilation success, took {diff_time:.2f} seconds"
321
- _print_banner(msg)
322
- return CompileResult(
323
- success=True,
324
- stdout=stdout,
325
- hash_value=hash_value,
326
- zip_bytes=out_buffer.getvalue(),
327
- )
328
302
  except KeyboardInterrupt:
329
303
  print("Keyboard interrupt")
330
304
  raise
fastled/zip_files.py ADDED
@@ -0,0 +1,76 @@
1
+ import io
2
+ import json
3
+ import zipfile
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from fastled.sketch import get_sketch_files
8
+ from fastled.types import BuildMode
9
+ from fastled.util import hash_file
10
+
11
+
12
+ def _file_info(file_path: Path) -> str:
13
+ hash_txt = hash_file(file_path)
14
+ file_size = file_path.stat().st_size
15
+ json_str = json.dumps({"hash": hash_txt, "size": file_size})
16
+ return json_str
17
+
18
+
19
+ @dataclass
20
+ class ZipResult:
21
+ zip_bytes: bytes
22
+ zip_embedded_bytes: bytes | None
23
+ success: bool
24
+ error: str | None
25
+
26
+
27
+ def zip_files(directory: Path, build_mode: BuildMode) -> ZipResult | Exception:
28
+ print("Zipping files...")
29
+ try:
30
+ files = get_sketch_files(directory)
31
+ if not files:
32
+ raise FileNotFoundError(f"No files found in {directory}")
33
+ for f in files:
34
+ print(f"Adding file: {f}")
35
+ # Create in-memory zip file
36
+ has_embedded_zip = False
37
+ zip_embedded_buffer = io.BytesIO()
38
+ zip_buffer = io.BytesIO()
39
+ with zipfile.ZipFile(
40
+ zip_embedded_buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9
41
+ ) as emebedded_zip_file:
42
+ with zipfile.ZipFile(
43
+ zip_buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9
44
+ ) as zip_file:
45
+ for file_path in files:
46
+ if "fastled_js" in str(file_path):
47
+ # These can be huge, don't send the output files back to the server!
48
+ continue
49
+ relative_path = file_path.relative_to(directory)
50
+ achive_path = str(Path("wasm") / relative_path)
51
+ if str(relative_path).startswith("data"):
52
+ _file_info_str = _file_info(file_path)
53
+ zip_file.writestr(
54
+ achive_path + ".embedded.json", _file_info_str
55
+ )
56
+ emebedded_zip_file.write(file_path, relative_path)
57
+ has_embedded_zip = True
58
+ else:
59
+ zip_file.write(file_path, achive_path)
60
+ # write build mode into the file as build.txt so that sketches are fingerprinted
61
+ # based on the build mode. Otherwise the same sketch with different build modes
62
+ # will have the same fingerprint.
63
+ zip_file.writestr(
64
+ str(Path("wasm") / "build_mode.txt"), build_mode.value
65
+ )
66
+ result = ZipResult(
67
+ zip_bytes=zip_buffer.getvalue(),
68
+ zip_embedded_bytes=(
69
+ zip_embedded_buffer.getvalue() if has_embedded_zip else None
70
+ ),
71
+ success=True,
72
+ error=None,
73
+ )
74
+ return result
75
+ except Exception as e:
76
+ return e
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastled
3
- Version: 1.4.2
3
+ Version: 1.4.4
4
4
  Summary: FastLED Wasm Compiler
5
5
  Home-page: https://github.com/zackees/fastled-wasm
6
6
  Maintainer: Zachary Vorhies
@@ -1,23 +1,24 @@
1
- fastled/__init__.py,sha256=oQFhIrBU0TbMDfQA1ZmubpZPwDzHmruXFa52H5UFSDE,7300
1
+ fastled/__init__.py,sha256=l4uDkh_YOd24okNfn6eWjtTYaZ0woeYC7khv-vHMmTM,7775
2
2
  fastled/__main__.py,sha256=OcKv2ER1_iQAsZzLIUb3C8hRC9L2clNOhCrjpshrlf4,336
3
- fastled/__version__.py,sha256=l6JsXGE7BZ3G8_6WmM2zKOTzyA6DYGmrSUUebpujEek,372
3
+ fastled/__version__.py,sha256=NeBy2hEASkJ8lb9Yhec88flAXoi8mapEcJBoqi2tBnA,372
4
4
  fastled/app.py,sha256=6XOuObi72AUnZXASDOVbcSflr4He0xnIDk5P8nVmVus,6131
5
5
  fastled/args.py,sha256=uCMyRIYM8gFE52O12YKUfA-rwJL8Zxwk_hsH3cusSac,3669
6
6
  fastled/cli.py,sha256=drgR2AOxVrj3QEz58iiKscYAumbbin2vIV-k91VCOAA,561
7
7
  fastled/cli_test.py,sha256=W-1nODZrip_JU6BEbYhxOa4ckxduOsiX8zIoRkTyxv4,550
8
8
  fastled/cli_test_interactive.py,sha256=BjNhveZOk5aCffHbcrxPQQjWmAuj4ClVKKcKX5eY6yM,542
9
- fastled/client_server.py,sha256=n3N-i7EgWtoyb3eR8TaCc3a3Mth9cUcYsyMNICtlJZc,20743
10
- fastled/compile_server.py,sha256=yQtwLOSKINO1CKD0NWxf-7YQKSatf9sF9RuqaWGOkCs,3038
11
- fastled/compile_server_impl.py,sha256=9vTGaDQ0W_g9Xsfy0gC3nJEc2g_pnXcF4VO2U3GLOVg,11982
9
+ fastled/client_server.py,sha256=mxRgVtUwsFHGMkCY-rIWUQqiKY6ovUtrWApkhrobrfc,21662
10
+ fastled/compile_server.py,sha256=iGUjteXKp5Dlp7mxAE4eD4s0NWgApRIp4ZjtcAN2iZY,3124
11
+ fastled/compile_server_impl.py,sha256=iCwNCs7YxypUuVPmY4979mOgoH9OiuAJa1a1bmpG1cc,12567
12
12
  fastled/docker_manager.py,sha256=rkq39ZKrU6NHIyDa3mzs0Unb6o9oMeAwxhqiuHJU_RY,40291
13
13
  fastled/filewatcher.py,sha256=gEcJJHTDJ1X3gKJzltmEBhixWGbZj2eJD7a4vwSvITQ,10036
14
+ fastled/find_good_connection.py,sha256=xnrJjrbwNZUkvSQRn_ZTMoVh5GBWTbO-lEsr_L95xq8,3372
14
15
  fastled/keyboard.py,sha256=UTAsqCn1UMYnB8YDzENiLTj4GeL45tYfEcO7_5fLFEg,3556
15
16
  fastled/keyz.py,sha256=LO-8m_7CpNDiZLM-FXhQ30f9gN1bUYz5lOsUPTIbI-c,4020
16
17
  fastled/live_client.py,sha256=aDZqSWDMpqNaEsT3u1nrBcdeIOItv-L0Gk2A10difLA,3209
17
18
  fastled/open_browser.py,sha256=mwjm65p2ydwmsaar7ooH4mhT5_qH_LZvXUpkRPPJ9eA,4881
18
19
  fastled/parse_args.py,sha256=htjap9tWZDJXnJ5upDwcy8EhecJD1uLZwacHR_T5ySs,11518
19
20
  fastled/paths.py,sha256=VsPmgu0lNSCFOoEC0BsTYzDygXqy15AHUfN-tTuzDZA,99
20
- fastled/playwright_browser.py,sha256=jNSIuWYz19t25-p4yj79oQog4l2njwehif9PiTIlLqk,20041
21
+ fastled/playwright_browser.py,sha256=jV0ckpMLoapYSMGABzuR21EYSh_RWa_WgIEm4w3QxTs,23417
21
22
  fastled/print_filter.py,sha256=nc_rqYYdCUPinFycaK7fiQF5PG1up51pmJptR__QyAs,1499
22
23
  fastled/project_init.py,sha256=bBt4DwmW5hZkm9ICt9Qk-0Nr_0JQM7icCgH5Iv-bCQs,3984
23
24
  fastled/select_sketch_directory.py,sha256=-eudwCns3AKj4HuHtSkZAFwbnf005SNL07pOzs9VxnE,1383
@@ -30,7 +31,8 @@ fastled/string_diff.py,sha256=oTncu0qYdLlLUtYLLDB4bzdQ2OfzegAR6XNAzwE9fIs,6002
30
31
  fastled/types.py,sha256=ZDf1TbTT4XgA_pKIwr4JbkDB38_29ogSdDORjoT-zuY,1803
31
32
  fastled/util.py,sha256=TjhXbUNh4p2BGhNAldSeL68B7BBOjsWAXji5gy-vDEQ,1440
32
33
  fastled/version.py,sha256=TpBMiEVdO3_sUZEu6wmwN8Q4AgX2BiCxStCsnPKh6E0,1209
33
- fastled/web_compile.py,sha256=BPrlrW7RMAtnbYUFF3LDn0ORx7aiMOuQkSlYF_gA1o0,11867
34
+ fastled/web_compile.py,sha256=Ql2DBRInZy7dOr1WZiUlhdg1ZVuU1nkbndRWiq7iENQ,10002
35
+ fastled/zip_files.py,sha256=BgHFjaLJ7wF6mnzjqOgn76VcKDwhwc_-w_qyUG_-aNs,2815
34
36
  fastled/assets/example.txt,sha256=lTBovRjiz0_TgtAtbA1C5hNi2ffbqnNPqkKg6UiKCT8,54
35
37
  fastled/assets/localhost-key.pem,sha256=Q-CNO_UoOd8fFNN4ljcnqwUeCMhzTplRjLO2x0pYRlU,1704
36
38
  fastled/assets/localhost.pem,sha256=QTwUtTwjYWbm9m3pHW2IlK2nFZJ8b0pppxPjhgVZqQo,1619
@@ -38,9 +40,9 @@ fastled/site/build.py,sha256=2YKU_UWKlJdGnjdbAbaL0co6kceFMSTVYwH1KCmgPZA,13987
38
40
  fastled/site/examples.py,sha256=s6vj2zJc6BfKlnbwXr1QWY1mzuDBMt6j5MEBOWjO_U8,155
39
41
  fastled/test/can_run_local_docker_tests.py,sha256=LEuUbHctRhNNFWcvnz2kEGmjDJeXO4c3kNpizm3yVJs,400
40
42
  fastled/test/examples.py,sha256=GfaHeY1E8izBl6ZqDVjz--RHLyVR4NRnQ5pBesCFJFY,1673
41
- fastled-1.4.2.dist-info/licenses/LICENSE,sha256=b6pOoifSXiUaz_lDS84vWlG3fr4yUKwB8fzkrH9R8bQ,1064
42
- fastled-1.4.2.dist-info/METADATA,sha256=-p4_m9LetykUugOdeUV-LvpupxDHMvf4ADqGbl339oo,32369
43
- fastled-1.4.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
44
- fastled-1.4.2.dist-info/entry_points.txt,sha256=RCwmzCSOS4-C2i9EziANq7Z2Zb4KFnEMR1FQC0bBwAw,101
45
- fastled-1.4.2.dist-info/top_level.txt,sha256=Bbv5kpJpZhWNCvDF4K0VcvtBSDMa8B7PTOrZa9CezHY,8
46
- fastled-1.4.2.dist-info/RECORD,,
43
+ fastled-1.4.4.dist-info/licenses/LICENSE,sha256=b6pOoifSXiUaz_lDS84vWlG3fr4yUKwB8fzkrH9R8bQ,1064
44
+ fastled-1.4.4.dist-info/METADATA,sha256=TTvZsjGN8o5yMhMNOhn1_DzlxObwPsTgu0RxeNV4n8A,32369
45
+ fastled-1.4.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
+ fastled-1.4.4.dist-info/entry_points.txt,sha256=RCwmzCSOS4-C2i9EziANq7Z2Zb4KFnEMR1FQC0bBwAw,101
47
+ fastled-1.4.4.dist-info/top_level.txt,sha256=Bbv5kpJpZhWNCvDF4K0VcvtBSDMa8B7PTOrZa9CezHY,8
48
+ fastled-1.4.4.dist-info/RECORD,,