#!/usr/bin/env python3 """ FastLED Example Compiler Streamlined compiler that uses the PioCompiler to build FastLED examples for various boards. This replaces the previous complex compilation system with a simpler approach using the Pio compiler. """ import argparse import os import sys import threading import time from concurrent.futures import Future, as_completed from dataclasses import dataclass from pathlib import Path from typing import List, Optional, cast from typeguard import typechecked from ci.boards import ALL, Board, create_board from ci.compiler.compiler import CacheType, SketchResult from ci.compiler.pio import PioCompiler def green_text(text: str) -> str: """Return text in green color.""" return f"\033[32m{text}\033[0m" def red_text(text: str) -> str: """Return text in red color.""" return f"\033[31m{text}\033[0m" def get_default_boards() -> List[str]: """Get all board names from the ALL boards list, with preferred boards first.""" # These are the boards we want to compile first (preferred order) # Order matters: UNO first because it's used for global init and builds faster preferred_board_names = [ "uno", # Build is faster if this is first, because it's used for global init. "esp32dev", "esp01", # ESP8266 "esp32c3", "esp32c6", "esp32s3", "teensylc", "teensy31", "teensy41", "digix", "rpipico", "rpipico2", ] # Get all available board names from the ALL list available_board_names = {board.board_name for board in ALL} # Start with preferred boards that exist, warn about missing ones default_boards: List[str] = [] for board_name in preferred_board_names: if board_name in available_board_names: default_boards.append(board_name) else: print( f"WARNING: Preferred board '{board_name}' not found in available boards" ) # Add all remaining boards (sorted for consistency) remaining_boards = sorted(available_board_names - set(default_boards)) default_boards.extend(remaining_boards) return default_boards def get_all_examples() -> List[str]: """Get all available example names from the examples directory.""" project_root = Path(__file__).parent.parent.resolve() examples_dir = project_root / "examples" if not examples_dir.exists(): return [] examples: List[str] = [] for item in examples_dir.iterdir(): if item.is_dir(): # Check if it contains a .ino file with the same name ino_file = item / f"{item.name}.ino" if ino_file.exists(): examples.append(item.name) # Sort for consistent ordering examples.sort() return examples def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( description="Compile FastLED examples for various boards using PioCompiler" ) parser.add_argument( "boards", type=str, help="Comma-separated list of boards to compile for", nargs="?", ) parser.add_argument( "positional_examples", type=str, help="Examples to compile (positional arguments after board name)", nargs="*", ) parser.add_argument( "--examples", type=str, help="Comma-separated list of examples to compile" ) parser.add_argument( "--exclude-examples", type=str, help="Examples that should be excluded" ) parser.add_argument( "--defines", type=str, help="Comma-separated list of compiler definitions" ) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable verbose output" ) parser.add_argument( "--no-cache", action="store_true", help="Disable sccache for faster compilation (default is already disabled)", ) parser.add_argument( "--enable-cache", action="store_true", help="Enable sccache for faster compilation", ) parser.add_argument( "--cache", action="store_true", help="(Deprecated) Enable sccache for faster compilation - use --enable-cache instead", ) parser.add_argument( "--supported-boards", action="store_true", help="Print the list of supported boards and exit", ) parser.add_argument( "--no-interactive", action="store_true", help="Disables interactive mode (deprecated)", ) parser.add_argument( "--log-failures", type=str, help="Directory to write per-example failure logs (created on first failure)", ) parser.add_argument( "--global-cache", type=str, help="Override global PlatformIO cache directory path (for testing)", ) parser.add_argument( "-o", "--out", type=str, help="Output path for build artifact. Requires exactly one sketch. " "If path ends with '/', treated as directory. If has suffix, treated as file. " "Use '-o .' to save in current directory with sketch name.", ) try: parsed_args = parser.parse_intermixed_args(args) unknown = [] except SystemExit: # If parse_intermixed_args fails, fall back to parse_known_args parsed_args, unknown = parser.parse_known_args(args) # Handle unknown arguments intelligently - treat non-flag arguments as examples unknown_examples: List[str] = [] for arg in unknown: if not arg.startswith("-"): unknown_examples.append(arg) # Add unknown examples to positional_examples if unknown_examples: if ( not hasattr(parsed_args, "positional_examples") or parsed_args.positional_examples is None ): parsed_args.positional_examples = [] # Type assertion to help the type checker - argparse returns Any but we know it's List[str] positional_examples: List[str] = cast( List[str], getattr(parsed_args, "positional_examples", []) ) positional_examples.extend(unknown_examples) parsed_args.positional_examples = positional_examples return parsed_args def resolve_example_path(example: str) -> str: """Resolve example name to path, ensuring it exists.""" project_root = Path(__file__).parent.parent.resolve() examples_dir = project_root / "examples" # Handle both "Blink" and "examples/Blink" formats if example.startswith("examples/"): example = example[len("examples/") :] example_path = examples_dir / example if not example_path.exists(): raise FileNotFoundError(f"Example not found: {example_path}") return example @typechecked @dataclass class BoardCompilationResult: """Aggregated result for compiling a set of examples on a single board.""" ok: bool sketch_results: List[SketchResult] def compile_board_examples( board: Board, examples: List[str], defines: List[str], verbose: bool, enable_cache: bool, global_cache_dir: Optional[Path] = None, ) -> BoardCompilationResult: """Compile examples for a single board using PioCompiler.""" # Resolve global cache directory immediately for display resolved_cache_dir = None if global_cache_dir is not None: # User specified a path - use it exactly as provided resolved_cache_dir = global_cache_dir.resolve() else: # Default path ends with 'global_cache' resolved_cache_dir = Path.home() / ".fastled" / "global_cache" print(f"\n{'=' * 60}") print(f"COMPILING BOARD: {board.board_name}") print(f"EXAMPLES: {', '.join(examples)}") print(f"GLOBAL CACHE: {resolved_cache_dir}") # Show other cache directories from ci.compiler.pio import FastLEDPaths paths = FastLEDPaths(board.board_name) print(f"BUILD CACHE: {paths.build_cache_dir}") print(f"CORE DIR: {paths.core_dir}") print(f"PACKAGES DIR: {paths.packages_dir}") print(f"{'=' * 60}") try: # Determine cache type based on flag and board frameworks frameworks = [f.strip().lower() for f in (board.framework or "").split(",")] mixed_frameworks = "arduino" in frameworks and "espidf" in frameworks cache_type = ( CacheType.SCCACHE if enable_cache and not mixed_frameworks else CacheType.NO_CACHE ) # Create PioCompiler instance compiler = PioCompiler( board=board, verbose=verbose, global_cache_dir=resolved_cache_dir, additional_defines=defines, cache_type=cache_type, ) # Build all examples futures: List[Future[SketchResult]] = compiler.build(examples) # Wait for completion and collect results results: List[SketchResult] = [] for future in futures: try: result = future.result() results.append(result) # SUCCESS/FAILED messages are printed by worker threads except KeyboardInterrupt: print("Keyboard interrupt detected, cancelling builds") compiler.cancel_all() import _thread for future in futures: future.cancel() _thread.interrupt_main() raise except Exception as e: # Represent unexpected exception as a failed SketchResult for consistency from pathlib import Path as _Path results.append( SketchResult( success=False, output=f"Build exception: {str(e)}", build_dir=_Path("."), example="", ) ) print(f"EXCEPTION during build: {e}") # Cleanup compiler.cancel_all() # Show compiler statistics after all builds complete try: stats = compiler.get_cache_stats() if stats: print("\n" + "=" * 60) print("COMPILER STATISTICS") print("=" * 60) print(stats) print("=" * 60) except Exception as e: print(f"Warning: Could not retrieve compiler statistics: {e}") any_failures = False for r in results: if not r.success: any_failures = True break return BoardCompilationResult(ok=not any_failures, sketch_results=results) except KeyboardInterrupt: print("Keyboard interrupt detected, cancelling builds") import _thread _thread.interrupt_main() raise except Exception as e: # Compiler could not be set up; return a single failed result to carry message from pathlib import Path as _Path return BoardCompilationResult( ok=False, sketch_results=[ SketchResult( success=False, output=f"Compiler setup failed: {str(e)}", build_dir=_Path("."), example="", ) ], ) def get_board_artifact_extension(board: Board) -> str: """Get the primary artifact extension for a board.""" # ESP32/ESP8266 boards always produce .bin files if board.board_name.startswith("esp"): return ".bin" # Most Arduino-based boards produce .hex files if board.framework and "arduino" in board.framework.lower(): return ".hex" # Default to .hex for most microcontroller boards return ".hex" def validate_output_path( output_path: str, sketch_name: str, board: Board ) -> tuple[bool, str, str]: """Validate output path and return (is_valid, resolved_path, error_message). Args: output_path: The user-specified output path sketch_name: Name of the sketch being built board: Board configuration Returns: Tuple of (is_valid, resolved_output_path, error_message) """ import os expected_ext = get_board_artifact_extension(board) # Handle special case: -o . if output_path == ".": resolved_path = f"{sketch_name}{expected_ext}" return True, resolved_path, "" # If path ends with /, it's a directory if output_path.endswith("/") or output_path.endswith("\\"): resolved_path = os.path.join(output_path, f"{sketch_name}{expected_ext}") return True, resolved_path, "" # If path has an extension, it's a file - validate the extension if "." in os.path.basename(output_path): _, ext = os.path.splitext(output_path) if ext != expected_ext: return ( False, "", f"Output file extension '{ext}' doesn't match expected '{expected_ext}' for board '{board.board_name}'", ) return True, output_path, "" # Path doesn't end with / and has no extension - treat as directory resolved_path = os.path.join(output_path, f"{sketch_name}{expected_ext}") return True, resolved_path, "" def copy_build_artifact( build_dir: Path, board: Board, sketch_name: str, output_path: str ) -> bool: """Copy the build artifact to the specified output path. Args: build_dir: Build directory path board: Board configuration sketch_name: Name of the sketch output_path: Target output path Returns: True if successful, False otherwise """ import os import shutil expected_ext = get_board_artifact_extension(board) # Find the source artifact # PlatformIO builds are in .build/pio/{board}/.pio/build/{board}/firmware.{ext} artifact_dir = build_dir / ".pio" / "build" / board.board_name source_artifact = artifact_dir / f"firmware{expected_ext}" if not source_artifact.exists(): print(f"ERROR: Build artifact not found: {source_artifact}") return False # Ensure output directory exists output_path_obj = Path(output_path) output_path_obj.parent.mkdir(parents=True, exist_ok=True) try: print(f"Copying {source_artifact} to {output_path}") shutil.copy2(source_artifact, output_path) print(f"✅ Build artifact saved to: {output_path}") return True except Exception as e: print(f"ERROR: Failed to copy build artifact: {e}") return False def main() -> int: """Main function.""" args = parse_args() if args.verbose: os.environ["VERBOSE"] = "1" if args.supported_boards: print(",".join(get_default_boards())) return 0 # Determine which boards to compile for if args.boards: boards_names = args.boards.split(",") else: boards_names = get_default_boards() # Get board objects boards: List[Board] = [] for board_name in boards_names: try: board = create_board(board_name, no_project_options=False) boards.append(board) except Exception as e: print(f"ERROR: Failed to get board '{board_name}': {e}") return 1 # Determine which examples to compile if args.positional_examples: # Convert positional examples, handling both "examples/Blink" and "Blink" formats examples: List[str] = [] for example in args.positional_examples: # Remove "examples/" prefix if present if example.startswith("examples/"): example = example[len("examples/") :] examples.append(example) elif args.examples: examples = args.examples.split(",") else: # Compile all available examples since builds are fast now! examples = get_all_examples() # Process example exclusions if args.exclude_examples: exclude_examples = args.exclude_examples.split(",") examples = [ex for ex in examples if ex not in exclude_examples] # Validate examples exist for example in examples: try: resolve_example_path(example) except FileNotFoundError as e: print(f"ERROR: {e}") return 1 # Validate -o/--out option requirements if args.out: if len(examples) != 1: print( f"ERROR: -o/--out option requires exactly one sketch, got {len(examples)}: {examples}" ) return 1 if len(boards) != 1: print( f"ERROR: -o/--out option requires exactly one board, got {len(boards)}: {[b.board_name for b in boards]}" ) return 1 # Validate the output path sketch_name = examples[0] board = boards[0] is_valid, resolved_output_path, error_msg = validate_output_path( args.out, sketch_name, board ) if not is_valid: print(f"ERROR: {error_msg}") return 1 # Set up defines defines: List[str] = [] if args.defines: defines.extend(args.defines.split(",")) # Start compilation start_time = time.time() print( f"Starting compilation for {len(boards)} boards with {len(examples)} examples" ) compilation_errors: List[str] = [] failed_example_names: List[str] = [] failure_logs_dir: Optional[Path] = ( Path(args.log_failures) if getattr(args, "log_failures", None) else None ) # Compile for each board for board in boards: # Parse global cache directory if provided global_cache_dir = None if args.global_cache: global_cache_dir = Path(args.global_cache) result = compile_board_examples( board=board, examples=examples, defines=defines, verbose=args.verbose, enable_cache=(args.enable_cache or args.cache) and not args.no_cache, global_cache_dir=global_cache_dir, ) if not result.ok: # Record board-level error compilation_errors.append(f"Board {board.board_name} failed") print(red_text(f"ERROR: Compilation failed for board {board.board_name}")) # Print each failing sketch's stdout and collect names for summary for sketch in result.sketch_results: if not sketch.success: if sketch.example and sketch.example not in failed_example_names: failed_example_names.append(sketch.example) # Write per-example failure logs when requested if failure_logs_dir is not None: try: failure_logs_dir.mkdir(parents=True, exist_ok=True) safe_name = f"{sketch.example}.log" log_path = failure_logs_dir / safe_name with log_path.open( "a", encoding="utf-8", errors="ignore" ) as f: f.write(sketch.output) f.write("\n") except KeyboardInterrupt: print("Keyboard interrupt detected, cancelling builds") import _thread _thread.interrupt_main() raise except Exception as e: print( f"Warning: Could not write failure log for {sketch.example}: {e}" ) print(f"\n{'-' * 60}") print(f"Sketch: {sketch.example}") print(f"{'-' * 60}") # Print the collected output for this sketch print(sketch.output) # Continue with other boards instead of stopping else: # Compilation succeeded - handle -o/--out option if specified if args.out: sketch_name = examples[0] # We already validated there's exactly one is_valid, resolved_output_path, _ = validate_output_path( args.out, sketch_name, board ) if is_valid: # Find the build directory for this board project_root = Path(__file__).parent.parent.resolve() build_dir = project_root / ".build" / "pio" / board.board_name if not copy_build_artifact( build_dir, board, sketch_name, resolved_output_path ): compilation_errors.append( f"Failed to copy artifact for {board.board_name}" ) print( red_text( f"ERROR: Failed to copy build artifact for {board.board_name}" ) ) return 1 # Report results elapsed_time = time.time() - start_time time_str = time.strftime("%Mm:%Ss", time.gmtime(elapsed_time)) if compilation_errors: print( f"\nCompilation finished in {time_str} with {len(compilation_errors)} error(s):" ) for error in compilation_errors: print(f" - {error}") if failed_example_names: print("") print(red_text(f"ERROR! There were {len(failed_example_names)} failures:")) # Sort for stable output for name in sorted(failed_example_names): # print(f" {name}") # same but in red print(red_text(f" {name}")) print("") return 1 else: print(f"\nAll compilations completed successfully in {time_str}") return 0 if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: print("\nInterrupted by user") sys.exit(1)