switch-pico/build.py

482 lines
15 KiB
Python

#!/usr/bin/env python3
"""Build and flash the project with optional grip color overrides."""
import argparse
import os
import random
import re
import shutil
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
FIRMWARE_SOURCE_DIR = SCRIPT_DIR / "src" / "firmware"
CONFIG_FILE = FIRMWARE_SOURCE_DIR / "platform" / "pico" / "controller_color_config.h"
BUILD_DIR = SCRIPT_DIR / "build"
AIO_BUILD_DIR = SCRIPT_DIR / "build-aio"
FEASIBILITY_BUILD_DIR = SCRIPT_DIR / "build-feasibility"
FIRMWARE_DIR = SCRIPT_DIR / "firmware"
FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico.elf"
FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico.uf2"
AIO_FIRMWARE_ELF_PATH = FIRMWARE_DIR / "switch-pico-aio.elf"
AIO_FIRMWARE_UF2_PATH = FIRMWARE_DIR / "switch-pico-aio.uf2"
FEASIBILITY_FIRMWARE_ELF_PATH = (
FIRMWARE_DIR / "switch-pico-adapter-feasibility.elf"
)
FEASIBILITY_FIRMWARE_UF2_PATH = (
FIRMWARE_DIR / "switch-pico-adapter-feasibility.uf2"
)
ELF_PATH = Path(os.environ.get("ELF_PATH", BUILD_DIR / "switch-pico.elf")).expanduser()
UF2_PATH = Path(os.environ.get("UF2_PATH", BUILD_DIR / "switch-pico.uf2")).expanduser()
MACROS = tuple(
f"SWITCH_COLOR_SLOT_{slot}_{component}"
for slot in range(1, 5)
for component in ("R", "G", "B")
)
CMAKE_CACHE_PATHS = tuple(
build_dir / "CMakeCache.txt"
for build_dir in (BUILD_DIR, AIO_BUILD_DIR, FEASIBILITY_BUILD_DIR)
)
TOOLCHAIN_COMPILER = (
"arm-none-eabi-gcc.exe" if os.name == "nt" else "arm-none-eabi-gcc"
)
class BuildEnvironmentError(RuntimeError):
"""A required Pico build dependency could not be resolved."""
def parse_cmake_cache(cache_path):
"""Return the simple key/value entries from an existing CMake cache."""
cache_path = Path(cache_path)
try:
lines = cache_path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return {}
entries = {}
for line in lines:
if not line or line.startswith(("//", "#")) or "=" not in line:
continue
key_and_type, value = line.split("=", 1)
key = key_and_type.split(":", 1)[0]
if key:
entries[key] = value
return entries
def _cache_path(value, cache_path):
path = Path(value).expanduser()
if not path.is_absolute():
path = cache_path.parent / path
return path
def _versioned_candidates(parent):
try:
return sorted(
(path for path in parent.iterdir() if path.is_dir()),
key=lambda path: path.name,
reverse=True,
)
except OSError:
return []
def _sdk_fallback_candidates():
yield ("project-local install", BUILD_DIR / "_deps" / "pico_sdk-src")
pico_sdk_home = Path.home() / ".pico-sdk" / "sdk"
for path in _versioned_candidates(pico_sdk_home):
yield ("user Pico SDK install", path)
yield ("user Pico SDK install", Path.home() / "pico" / "pico-sdk")
yield ("user Pico SDK install", Path.home() / "pico-sdk")
yield ("system Pico SDK install", Path("/opt/pico-sdk"))
yield ("system Pico SDK install", Path("/usr/local/pico-sdk"))
yield ("system Pico SDK install", Path("/usr/share/pico-sdk"))
def _toolchain_fallback_candidates():
yield ("project-local install", BUILD_DIR / "toolchain")
pico_toolchain_home = Path.home() / ".pico-sdk" / "toolchain"
for path in _versioned_candidates(pico_toolchain_home):
yield ("user Pico toolchain install", path)
yield (
"user Pico toolchain install",
Path.home() / "pico" / "arm-none-eabi-gcc",
)
yield ("user Pico toolchain install", Path.home() / "arm-none-eabi-gcc")
yield ("system Pico toolchain install", Path("/opt/arm-none-eabi-gcc"))
yield (
"system Pico toolchain install",
Path("/usr/local/arm-none-eabi-gcc"),
)
def _valid_sdk(path):
return (path / "pico_sdk_init.cmake").is_file()
def _valid_toolchain(path):
return (path / "bin" / TOOLCHAIN_COMPILER).is_file()
def _first_valid_candidate(candidates, validator):
for source, candidate in candidates:
path = Path(candidate).expanduser()
if validator(path):
return path, source
return None, None
def _cache_candidates(cache_paths, variables):
for cache_path in cache_paths:
cache_path = Path(cache_path)
entries = parse_cmake_cache(cache_path)
for variable, compiler_path in variables:
value = entries.get(variable)
if not value:
continue
path = _cache_path(value, cache_path)
if compiler_path:
path = path.parent.parent
yield (f"cache {cache_path}", path)
def _explicit_path(environ, variable, validator, expected):
if variable not in environ:
return None
value = environ[variable]
if not value:
raise BuildEnvironmentError(f"{variable} is set but empty.")
path = Path(value).expanduser()
if not validator(path):
raise BuildEnvironmentError(
f"{variable} is set to {path}, but {expected} was not found."
)
return path
def configure_pico_environment(
*,
environ=None,
cache_paths=None,
sdk_candidates=None,
toolchain_candidates=None,
which=None,
):
"""Resolve Pico dependencies and apply auto-detected environment values."""
environ = os.environ if environ is None else environ
cache_paths = CMAKE_CACHE_PATHS if cache_paths is None else cache_paths
which = shutil.which if which is None else which
explicit_sdk = _explicit_path(
environ,
"PICO_SDK_PATH",
_valid_sdk,
"pico_sdk_init.cmake",
)
explicit_toolchain = _explicit_path(
environ,
"PICO_TOOLCHAIN_PATH",
_valid_toolchain,
f"bin/{TOOLCHAIN_COMPILER}",
)
sdk_path = explicit_sdk
sdk_source = None
if sdk_path is None:
sdk_path, sdk_source = _first_valid_candidate(
_cache_candidates(
cache_paths,
(("PICO_SDK_PATH", False),),
),
_valid_sdk,
)
if sdk_path is None:
sdk_path, sdk_source = _first_valid_candidate(
(
_sdk_fallback_candidates()
if sdk_candidates is None
else sdk_candidates
),
_valid_sdk,
)
compiler_on_path = which(
TOOLCHAIN_COMPILER,
path=environ.get("PATH", ""),
)
toolchain_path = explicit_toolchain
toolchain_source = None
if toolchain_path is None and compiler_on_path is None:
toolchain_path, toolchain_source = _first_valid_candidate(
_cache_candidates(
cache_paths,
(
("PICO_TOOLCHAIN_PATH", False),
("CMAKE_C_COMPILER", True),
("PICO_COMPILER_CC", True),
),
),
_valid_toolchain,
)
if toolchain_path is None:
toolchain_path, toolchain_source = _first_valid_candidate(
(
_toolchain_fallback_candidates()
if toolchain_candidates is None
else toolchain_candidates
),
_valid_toolchain,
)
missing = []
if sdk_path is None:
missing.append("Pico SDK (set PICO_SDK_PATH)")
if toolchain_path is None and compiler_on_path is None:
missing.append(
"Arm GNU toolchain "
"(set PICO_TOOLCHAIN_PATH or add arm-none-eabi-gcc to PATH)"
)
if missing:
raise BuildEnvironmentError(
"Missing build prerequisite(s): " + "; ".join(missing) + "."
)
updates = {}
detected = []
if explicit_sdk is None:
updates["PICO_SDK_PATH"] = str(sdk_path)
detected.append(("PICO_SDK_PATH", sdk_path, sdk_source))
if explicit_toolchain is None and compiler_on_path is None:
updates["PICO_TOOLCHAIN_PATH"] = str(toolchain_path)
detected.append(
("PICO_TOOLCHAIN_PATH", toolchain_path, toolchain_source)
)
environ.update(updates)
for variable, path, source in detected:
print(f"Auto-detected {variable}={path} ({source})")
return updates
def parse_args():
parser = argparse.ArgumentParser(
description="Build and flash the project, optionally setting grip colors.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"Default behavior leaves {CONFIG_FILE.relative_to(SCRIPT_DIR)} unchanged.",
)
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
"--aio",
action="store_true",
help="Build and flash the Pico 2 W Bluepad32 all-in-one firmware.",
)
mode_group.add_argument(
"--adapter-feasibility",
action="store_true",
help="Build and flash the Pico 2 W automatic Switch/XInput prototype.",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--random-grip-color",
action="store_true",
help="Assign one random color to every emulated controller slot.",
)
group.add_argument(
"--grip-color",
metavar="RRGGBB",
help="Set every emulated controller slot to the provided hex color.",
)
return parser.parse_args()
def random_hex_color():
return "".join(f"{random.randrange(256):02X}" for _ in range(3))
def validate_custom_color(value):
if not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
raise ValueError("Color must be a 6-digit hex value like FF8800.")
return value
def update_grip_colors(rgb_hex):
if not CONFIG_FILE.exists():
sys.stderr.write(f"Error: Cannot find {CONFIG_FILE}\n")
sys.exit(1)
r, g, b = rgb_hex[:2], rgb_hex[2:4], rgb_hex[4:6]
try:
text = CONFIG_FILE.read_text(encoding="utf-8")
except OSError as exc:
sys.stderr.write(f"Error reading {CONFIG_FILE}: {exc}\n")
sys.exit(1)
def replace(name, val, data):
pattern = rf"(?m)^(#define\s+{name}\s+)0x[0-9A-Fa-f]{{2}}"
updated, count = re.subn(pattern, rf"\g<1>0x{val.upper()}", data)
if count == 0:
sys.stderr.write(f"Error: Could not find {name} in {CONFIG_FILE}\n")
sys.exit(1)
return updated
values = (r, g, b) * 4
for macro, val in zip(MACROS, values):
text = replace(macro, val, text)
try:
CONFIG_FILE.write_text(text, encoding="utf-8")
except OSError as exc:
sys.stderr.write(f"Error writing {CONFIG_FILE}: {exc}\n")
sys.exit(1)
def run_cmd(command):
try:
subprocess.run(command, cwd=SCRIPT_DIR, check=True)
except FileNotFoundError as exc:
sys.stderr.write(f"Error running {command[0]}: {exc}\n")
sys.exit(1)
except subprocess.CalledProcessError as exc:
sys.exit(exc.returncode)
def resolve_picotool():
env_val = os.environ.get("PICOTOOL_PATH")
if env_val:
env_path = Path(env_val).expanduser()
if not env_path.exists():
sys.stderr.write(f"Error: PICOTOOL_PATH set to {env_path}, but it does not exist.\n")
sys.exit(1)
return env_path
found = shutil.which("picotool")
if found:
return Path(found)
sys.stderr.write("Error: picotool not found. Put it on your PATH or set PICOTOOL_PATH.\n")
sys.exit(1)
def build(
aio,
adapter_feasibility,
build_dir,
elf_path,
uf2_path,
firmware_elf_path,
firmware_uf2_path,
):
if aio or adapter_feasibility:
run_cmd([sys.executable, str(SCRIPT_DIR / "tools" / "prepare_bluepad32.py")])
definitions = [
"-DSWITCH_PICO_LOG=OFF",
"-DPICO_BOARD=pico2_w",
"-DSWITCH_PICO_INPUT_BACKEND=BLUEPAD32",
]
if adapter_feasibility:
definitions.append("-DSWITCH_PICO_ADAPTER_FEASIBILITY=ON")
else:
definitions = [
"-DSWITCH_PICO_LOG=OFF",
"-DPICO_BOARD=pico",
"-DSWITCH_PICO_INPUT_BACKEND=UART",
]
run_cmd(
[
"cmake",
"-S",
str(SCRIPT_DIR),
"-B",
str(build_dir),
*definitions,
]
)
run_cmd(["cmake", "--build", str(build_dir)])
missing_artifacts = [
path for path in (elf_path, uf2_path) if not path.is_file()
]
if missing_artifacts:
missing = ", ".join(str(path) for path in missing_artifacts)
sys.stderr.write(f"Error: Build did not produce required artifact(s): {missing}\n")
sys.exit(1)
FIRMWARE_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(elf_path, firmware_elf_path)
shutil.copy2(uf2_path, firmware_uf2_path)
print(f"Built ELF: {elf_path}")
print(f"Built UF2: {uf2_path}")
print(f"Copied ELF: {firmware_elf_path}")
print(f"Copied UF2: {firmware_uf2_path}")
def flash(elf_path, allow_elf_override):
picotool = resolve_picotool()
if not elf_path.exists():
if allow_elf_override:
sys.stderr.write(
f"Error: Cannot find ELF at {elf_path}. Set ELF_PATH to override.\n"
)
else:
sys.stderr.write(f"Error: Cannot find ELF at {elf_path}.\n")
sys.exit(1)
run_cmd([str(picotool), "load", str(elf_path), "-fx"])
def main():
args = parse_args()
try:
configure_pico_environment()
except BuildEnvironmentError as exc:
sys.stderr.write(f"Error: {exc}\n")
sys.exit(1)
color = None
if args.random_grip_color:
color = random_hex_color()
elif args.grip_color:
try:
color = validate_custom_color(args.grip_color)
except ValueError as exc:
sys.stderr.write(f"Error: {exc}\n")
sys.exit(1)
if color:
update_grip_colors(color)
print(f"Grip color set to #{color} in {CONFIG_FILE.name}")
if args.adapter_feasibility:
build_dir = FEASIBILITY_BUILD_DIR
elf_path = FEASIBILITY_BUILD_DIR / "switch-pico.elf"
uf2_path = FEASIBILITY_BUILD_DIR / "switch-pico.uf2"
firmware_elf_path = FEASIBILITY_FIRMWARE_ELF_PATH
firmware_uf2_path = FEASIBILITY_FIRMWARE_UF2_PATH
elif args.aio:
build_dir = AIO_BUILD_DIR
elf_path = AIO_BUILD_DIR / "switch-pico.elf"
uf2_path = AIO_BUILD_DIR / "switch-pico.uf2"
firmware_elf_path = AIO_FIRMWARE_ELF_PATH
firmware_uf2_path = AIO_FIRMWARE_UF2_PATH
else:
build_dir = BUILD_DIR
elf_path = ELF_PATH
uf2_path = UF2_PATH
firmware_elf_path = FIRMWARE_ELF_PATH
firmware_uf2_path = FIRMWARE_UF2_PATH
build(
args.aio,
args.adapter_feasibility,
build_dir,
elf_path,
uf2_path,
firmware_elf_path,
firmware_uf2_path,
)
flash(
elf_path,
allow_elf_override=not args.aio and not args.adapter_feasibility,
)
if __name__ == "__main__":
main()