Files
tactility/Tests/SdkIntegration/tactility.py
2026-09-09 20:05:04 +02:00

1040 lines
41 KiB
Python

import json
import os
import re
import shutil
import sys
import subprocess
import time
import urllib.request
import zipfile
import requests
import tarfile
from urllib.parse import urlparse
ttbuild_path = ".tactility"
ttbuild_version = "6.0.0"
ttbuild_cdn = "https://cdn.tactilityproject.org"
ttbuild_sdk_json_validity = 3600 # seconds
ttport = 6666
verbose = False
use_local_sdk = False
local_base_path = None
http_timeout_seconds = 10
# App install uploads the whole package over HTTP and the device only responds once it's
# fully received, extracted and registered; large packages (e.g. bundled fonts/assets) can
# easily take well over http_timeout_seconds on a slow SD card, so give it a lot more room.
install_timeout_seconds = 120
shell_color_red = "\033[91m"
shell_color_orange = "\033[93m"
shell_color_green = "\033[32m"
shell_color_purple = "\033[35m"
shell_color_cyan = "\033[36m"
shell_color_reset = "\033[m"
def print_help():
print("Usage: python tactility.py [action] [options]")
print("")
print("Actions:")
print("")
print(" build Build the app. Optionally specify a platform.")
print(" Supported platforms are lower case. Example: esp32s3")
print(" Supported platforms are read from manifest.properties")
print(" Parameters:")
print(" (optional) --architecture [input], -a [input]")
print("")
print(" clean Clean the build folders")
print("")
print(" clearcache Clear the SDK cache")
print("")
print(" updateself Update this tool")
print("")
print(" run Run the application")
print(" Parameters:")
print(" (required) --host, -h [input]")
print("")
print(" install Install the application")
print(" Parameters:")
print(" (required) --host, -h [input]")
print("")
print(" uninstall Uninstall the application")
print(" Parameters:")
print(" (required) --host, -h [input]")
print("")
print(" bir Build, install then run.")
print(" Parameters:")
print(" (required) --host, -h [input]")
print(" (optional) --architecture, -a [input]")
print("")
print(" brrr Functionally the same as \"bir\", but \"app goes brrr\" meme variant.")
print(" Parameters:")
print(" (required) --host, -h [input]")
print(" (optional) --architecture, -a [input]")
print("")
print("Options:")
print(" -p, --path [input] Path to the app directory (defaults to current directory)")
print(" --help Show this commandline info")
print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.")
print(" --skip-build Run everything except the idf.py/CMake commands")
print(" --verbose Show extra console output")
print("")
print("Examples:")
print(" python tactility.py build")
print(" python tactility.py build --path Apps/Snake --architecture esp32s3 --verbose")
print(" python tactility.py bir --path Apps/Snake --host 192.168.1.50 --architecture esp32s3")
# region Core
def download_file(url, filepath):
global verbose
if verbose:
print(f"Downloading from {url} to {filepath}")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
print_error(f"Unsupported URL scheme: {parsed.scheme}")
return False
request = urllib.request.Request(
url,
data=None,
headers={
"User-Agent": f"Tactility Build Tool {ttbuild_version}"
}
)
try:
with urllib.request.urlopen(request, timeout=30) as response, open(filepath, mode="wb") as file:
file.write(response.read())
return True
except OSError as error:
if verbose:
print_error(f"Failed to fetch URL {url}\n{error}")
return False
def print_warning(message):
print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}")
def print_error(message):
print(f"{shell_color_red}ERROR: {message}{shell_color_reset}")
def print_status_busy(status):
sys.stdout.write(f"⌛ {status}\r")
def print_status_success(status):
# Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text
print(f"✅ {shell_color_green}{status}{shell_color_reset} ")
def print_status_error(status):
# Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text
print(f"❌ {shell_color_red}{status}{shell_color_reset} ")
def exit_with_error(message):
print_error(message)
sys.exit(1)
def get_url(host, path):
return f"http://{host}:{ttport}{path}"
def read_properties_file(path):
properties = {}
with open(path, "r") as file:
for line in file:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
continue
properties[key.strip()] = value.strip()
return properties
#endregion Core
#region Versioning
class SemanticVersion:
def __init__(self, major, minor, patch, tag=None):
self.major = major
self.minor = minor
self.patch = patch
self.tag = tag
@staticmethod
def parse(version_string):
match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", version_string)
if match is None:
exit_with_error(f"Invalid version format: {version_string}")
major, minor, patch, tag = match.groups()
return SemanticVersion(int(major), int(minor), int(patch), tag)
def _numeric(self):
return (self.major, self.minor, self.patch)
def __eq__(self, other):
return self._numeric() == other._numeric() and self.tag == other.tag
def __lt__(self, other):
if self._numeric() != other._numeric():
return self._numeric() < other._numeric()
# Same major.minor.patch: an untagged version outranks any tagged (pre-release) one.
if self.tag == other.tag:
return False
if self.tag is None:
return False
if other.tag is None:
return True
return self.tag < other.tag
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}" + (f"-{self.tag}" if self.tag else "")
# Ignores tags: a "-dev"/"-rc1"-tagged pre-release build of a version is still that version,
# not an older one, unlike __lt__'s full ordering (which ranks pre-releases below releases).
def is_older_release_of(self, other):
return self._numeric() < other._numeric()
# Append a new entry whenever the tool drops support for older SDKs.
# The last entry is the currently effective minimum.
SDK_COMPATIBILITY_LEDGER = [
SemanticVersion.parse("0.8.0"),
]
def minimum_supported_sdk_version():
return SDK_COMPATIBILITY_LEDGER[-1]
def validate_sdk_compatibility(target_sdk_version):
minimum = minimum_supported_sdk_version()
if SemanticVersion.parse(target_sdk_version).is_older_release_of(minimum):
exit_with_error(
f"This tool requires SDK version {minimum} or newer "
f"(manifest.properties requests {target_sdk_version})"
)
#endregion Versioning
#region Argument parsing
# canonical name -> (short flag or None, long flag, takes_value)
ARG_SPECS = {
"path": ("-p", "--path", True),
"host": ("-h", "--host", True),
"platform": ("-a", "--architecture", True),
"verbose": (None, "--verbose", False),
"skip-build": (None, "--skip-build", False),
"local-sdk": (None, "--local-sdk", False),
"help": (None, "--help", False),
}
class Arguments:
def __init__(self, values):
self.values = values
def get(self, key, default=None):
return self.values.get(key, default)
def has(self, key):
return key in self.values
class ParsedCommand:
def __init__(self, action, args):
self.action = action
self.arguments = args
def parse_command_line(argv):
token_to_spec = {}
for canonical, (short, long, takes_value) in ARG_SPECS.items():
if short is not None:
token_to_spec[short] = (canonical, takes_value)
token_to_spec[long] = (canonical, takes_value)
action = None
args = {}
index = 0
while index < len(argv):
token = argv[index]
if token in token_to_spec:
canonical, takes_value = token_to_spec[token]
if takes_value:
index += 1
if index >= len(argv):
exit_with_error(f"Missing value for {token}")
args[canonical] = argv[index]
else:
args[canonical] = "true"
elif action is None:
action = token
else:
exit_with_error(f"Unexpected argument: {token}")
index += 1
return ParsedCommand(action, Arguments(args))
#endregion Argument parsing
#region SDK helpers
def read_sdk_json():
json_file_path = os.path.join(ttbuild_path, "tool.json")
with open(json_file_path) as json_file:
return json.load(json_file)
def get_sdk_dir(version, platform):
global use_local_sdk, local_base_path
if use_local_sdk:
base_path = local_base_path
if base_path is None:
exit_with_error("TACTILITY_SDK_PATH environment variable is not set")
sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}")
sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK")
if not os.path.isdir(sdk_dir):
exit_with_error(f"Local SDK folder not found for platform {platform}: {sdk_dir}")
return os.path.abspath(sdk_dir)
else:
# Must be absolute: this is exported as $TACTILITY_SDK_PATH and included by each app's
# main/CMakeLists.txt, which ESP-IDF also re-processes in a separate `cmake -P` subprocess
# (tools/cmake/scripts/component_get_requirements.cmake) with its own working directory -
# a relative path here resolves against whatever CWD that subprocess happens to have.
return os.path.abspath(os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK"))
def validate_local_sdks(platforms, version):
if not use_local_sdk:
return
global local_base_path
base_path = local_base_path
for platform in platforms:
sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}")
sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK")
if not os.path.isdir(sdk_dir):
exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}")
def get_sdk_root_dir(version, platform):
global ttbuild_cdn
return os.path.join(ttbuild_path, f"{version}-{platform}")
def get_sdk_url(version, file):
global ttbuild_cdn
return f"{ttbuild_cdn}/sdk/{version}/{file}"
def sdk_exists(version, platform):
sdk_dir = get_sdk_dir(version, platform)
return os.path.isdir(sdk_dir)
def should_update_tool_json():
global ttbuild_cdn
json_filepath = os.path.join(ttbuild_path, "tool.json")
if os.path.exists(json_filepath):
json_modification_time = os.path.getmtime(json_filepath)
now = time.time()
global ttbuild_sdk_json_validity
minimum_seconds_difference = ttbuild_sdk_json_validity
return (now - json_modification_time) > minimum_seconds_difference
else:
return True
def update_tool_json():
global ttbuild_cdn, ttbuild_path
json_url = f"{ttbuild_cdn}/sdk/tool.json"
json_filepath = os.path.join(ttbuild_path, "tool.json")
return download_file(json_url, json_filepath)
def should_fetch_sdkconfig_files(platform_targets):
for platform in platform_targets:
if not platform.startswith("posix"):
sdkconfig_filename = f"sdkconfig.app.{platform}"
if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)):
return True
return False
def fetch_sdkconfig_files(platform_targets):
for platform in platform_targets:
if not platform.startswith("posix"):
sdkconfig_filename = f"sdkconfig.app.{platform}"
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
exit_with_error(f"Failed to download sdkconfig file for {platform}")
#endregion SDK helpers
#region Validation
def validate_environment(platforms):
if any(not platform.startswith("posix") for platform in platforms) and os.environ.get("IDF_PATH") is None:
if sys.platform == "win32":
exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1")
else:
exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh")
if not os.path.exists("manifest.properties"):
exit_with_error("manifest.properties not found")
if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None:
print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.")
print_warning("If you want to use it, use the '--local-sdk' parameter")
elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None:
exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.")
def validate_self(sdk_json):
if not "toolVersion" in sdk_json:
exit_with_error("Server returned invalid SDK data format (toolVersion not found)")
if not "toolCompatibility" in sdk_json:
exit_with_error("Server returned invalid SDK data format (toolCompatibility not found)")
if not "toolDownloadUrl" in sdk_json:
exit_with_error("Server returned invalid SDK data format (toolDownloadUrl not found)")
tool_version = sdk_json["toolVersion"]
tool_compatibility = sdk_json["toolCompatibility"]
if SemanticVersion.parse(ttbuild_version) < SemanticVersion.parse(tool_version):
print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})")
print_warning(f"Run 'tactility.py updateself' to update.")
if re.search(tool_compatibility, ttbuild_version) is None:
print_error("The tool is not compatible anymore.")
print_error("Run 'tactility.py updateself' to update.")
sys.exit(1)
#endregion Validation
#region Manifest
def read_manifest():
return read_properties_file("manifest.properties")
def is_v3_manifest(manifest):
return manifest.get("manifest.version") == "0.3"
# The package identifier: a v2 manifest has no package/app distinction, so its single "app.id"
# doubles as both; a v3 manifest's package id is the bare "id" key, distinct from any of its
# (possibly several) "app.N.id" app ids.
def get_package_id(manifest):
return manifest["id"] if is_v3_manifest(manifest) else manifest["app.id"]
# The id of the app "run"/"install" operate on. A v2 manifest has exactly one; a v3 manifest may
# declare several, so this picks the first ("app.0.id") as the one a single-app dev workflow means.
def get_primary_app_id(manifest):
return manifest["app.0.id"] if is_v3_manifest(manifest) else manifest["app.id"]
def validate_manifest(manifest):
for key in ("manifest.version", "target.sdk", "target.platforms"):
if key not in manifest:
exit_with_error(f"Invalid manifest format: {key} not found")
if manifest["manifest.version"] not in ("0.2", "0.3"):
exit_with_error(f"Unsupported manifest.version: {manifest['manifest.version']}")
if is_v3_manifest(manifest):
for key in ("id", "version.name", "version.code"):
if key not in manifest:
exit_with_error(f"Invalid manifest format: {key} not found")
if "app.0.id" not in manifest:
exit_with_error("Invalid manifest format: app.0.id not found")
index = 0
while f"app.{index}.id" in manifest:
for suffix in ("name", "binary"):
key = f"app.{index}.{suffix}"
if key not in manifest:
exit_with_error(f"Invalid manifest format: {key} not found")
index += 1
else:
for key in ("app.id", "app.version.name", "app.version.code", "app.name"):
if key not in manifest:
exit_with_error(f"Invalid manifest format: {key} not found")
validate_sdk_compatibility(manifest["target.sdk"])
# Maps each binary this app builds to the directory its own CMake project lives in. A v2
# manifest always describes exactly one app, built at the app's own root (see
# package_intermediate_binaries() for its fixed "app.{elf,so}" package filename, matching
# package_manifest_parse_v2()'s single implicit binary). A v3 manifest declaring a single
# "app.0.*" block is the same case: built at the app's own root. Only when a v3 manifest
# declares more than one "app.N.*" block (0-indexed, contiguous - the first missing "app.N.id"
# ends the list, mirroring package_manifest_parse_v3()) does each get its own subdirectory,
# named after its own "app.N.binary" (the filename it installs as - see
# package_manifest_parse_v3()'s doc).
def get_binary_dirs(manifest):
if is_v3_manifest(manifest):
binaries = []
index = 0
while f"app.{index}.id" in manifest:
binary = manifest[f"app.{index}.binary"]
binaries.append((binary, binary))
index += 1
if len(binaries) == 1:
return [(binaries[0][0], ".")]
for binary, directory in binaries:
if not os.path.isdir(directory):
exit_with_error(f"Binary directory not found for '{binary}': {directory}")
return binaries
else:
return [(manifest["app.id"], ".")]
def is_valid_manifest_platform(manifest, platform):
manifest_platforms = manifest["target.platforms"].split(",")
return platform in manifest_platforms
def validate_manifest_platform(manifest, platform):
if not is_valid_manifest_platform(manifest, platform):
exit_with_error(f"Platform {platform} is not available in the manifest.")
def get_manifest_target_platforms(manifest, requested_platform):
if requested_platform == "" or requested_platform is None:
return manifest["target.platforms"].split(",")
else:
validate_manifest_platform(manifest, requested_platform)
return [requested_platform]
#endregion Manifest
#region SDK download
def safe_extract_zip(zip_ref, target_dir):
target_dir = os.path.realpath(target_dir)
for member in zip_ref.infolist():
dest = os.path.realpath(os.path.join(target_dir, member.filename))
if not dest.startswith(target_dir + os.sep):
raise ValueError(f"Invalid zip entry: {member.filename}")
zip_ref.extractall(target_dir)
def sdk_download(version, platform):
sdk_root_dir = get_sdk_root_dir(version, platform)
os.makedirs(sdk_root_dir, exist_ok=True)
sdk_index_url = get_sdk_url(version, "index.json")
print(f"Downloading SDK version {version} for {platform}")
sdk_index_filepath = os.path.join(sdk_root_dir, "index.json")
if verbose:
print(f"Downloading {sdk_index_url} to {sdk_index_filepath}")
if not download_file(sdk_index_url, sdk_index_filepath):
# TODO: 404 check, print a more accurate error
print_error(f"Failed to download SDK version {version}. Check your internet connection and make sure this release exists.")
return False
with open(sdk_index_filepath) as sdk_index_json_file:
sdk_index_json = json.load(sdk_index_json_file)
sdk_platforms = sdk_index_json["platforms"]
if platform not in sdk_platforms:
print_error(f"Platform {platform} not found in {sdk_platforms} for version {version}")
return False
sdk_platform_file = sdk_platforms[platform]
sdk_zip_source_url = get_sdk_url(version, sdk_platform_file)
sdk_zip_target_filepath = os.path.join(sdk_root_dir, f"{version}-{platform}.zip")
if verbose:
print(f"Downloading {sdk_zip_source_url} to {sdk_zip_target_filepath}")
if not download_file(sdk_zip_source_url, sdk_zip_target_filepath):
print_error(f"Failed to download {sdk_zip_source_url} to {sdk_zip_target_filepath}")
return False
with zipfile.ZipFile(sdk_zip_target_filepath, "r") as zip_ref:
safe_extract_zip(zip_ref, os.path.join(sdk_root_dir, "TactilitySDK"))
return True
def sdk_download_all(version, platforms):
for platform in platforms:
if not sdk_exists(version, platform):
if not sdk_download(version, platform):
return False
else:
if verbose:
print(f"Using cached download for SDK version {version} and platform {platform}")
return True
#endregion SDK download
#region CMakeLists scaffolding
# Bump whenever CMAKELISTS_TEMPLATE below changes, so existing apps' generated CMakeLists.txt get
# regenerated on their next build instead of silently going stale.
CMAKELISTS_VERSION = 1
CMAKELISTS_TEMPLATE = """# tactility-cmakelists-version: %(version)d
cmake_minimum_required(VERSION 3.20)
if (NOT DEFINED ENV{TACTILITY_SDK_PATH})
message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set")
endif()
get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE)
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
tactility_project_pre(%(app_id)s)
project(%(app_id)s)
tactility_project_post(%(app_id)s)
"""
def cmakelists_version_marker():
return f"# tactility-cmakelists-version: {CMAKELISTS_VERSION}"
def ensure_cmakelists_up_to_date(project_id):
marker = cmakelists_version_marker()
if os.path.exists("CMakeLists.txt"):
with open("CMakeLists.txt", "r") as file:
first_line = file.readline().rstrip("\n")
if first_line == marker:
return
print(f"Updating CMakeLists.txt to {marker}")
content = CMAKELISTS_TEMPLATE % {"version": CMAKELISTS_VERSION, "app_id": project_id}
with open("CMakeLists.txt", "w") as file:
file.write(content)
#endregion CMakeLists scaffolding
#region Building
def get_cmake_path(platform):
return os.path.join("build", f"cmake-build-{platform}")
def find_elf_file(platform):
cmake_dir = get_cmake_path(platform)
if not os.path.exists(cmake_dir):
return None
# POSIX apps are dlopen()ed shared objects (app-posix-module), not idf.py/elf_loader
# relocatable images, so they land as a plain ".so" instead of "*.app.elf".
suffix = ".so" if platform.startswith("posix") else ".app.elf"
for file in os.listdir(cmake_dir):
if file.endswith(suffix):
return os.path.join(cmake_dir, file)
return None
def get_posix_build_env(sdk_dir):
# A dev shell may already have ESP-IDF's export.sh sourced; strip it so the SDK's plain-CMake
# top-level CMakeLists.txt takes the POSIX branch instead of the idf.py one.
env = os.environ.copy()
env.pop("ESP_IDF_VERSION", None)
env.pop("IDF_PATH", None)
env["TACTILITY_SDK_PATH"] = sdk_dir
return env
def build_posix(version, platform, skip_build):
sdk_dir = get_sdk_dir(version, platform)
if verbose:
print(f"Using SDK at {sdk_dir}")
if skip_build:
return True
env = get_posix_build_env(sdk_dir)
cmake_path = get_cmake_path(platform)
print_status_busy(f"Building {platform}")
configure_command = ["cmake", "-S", ".", "-B", cmake_path, "-G", "Ninja"]
if verbose:
print(f"Running command: {' '.join(configure_command)}")
configure_result = subprocess.run(configure_command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if configure_result.returncode != 0:
print(configure_result.stdout.decode("UTF-8"), end="")
print_status_error(f"Configuring {platform}")
return False
build_command = ["cmake", "--build", cmake_path]
if verbose:
print(f"Running command: {' '.join(build_command)}")
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) as process:
build_output = wait_for_process(process)
if process.returncode == 0:
print_status_success(f"Building {platform}")
return True
else:
for line in build_output:
print(line, end="")
print_status_error(f"Building {platform}")
return False
def build_all(version, platforms, skip_build):
for platform in platforms:
if platform.startswith("posix"):
if not build_posix(version, platform, skip_build):
return False
# First build command must be "idf.py build", otherwise it fails to execute "idf.py elf"
# We check if the ELF file exists and run the correct command
# This can lead to code caching issues, so sometimes a clean build is required
elif find_elf_file(platform) is None:
if not build_first(version, platform, skip_build):
return False
else:
if not build_consecutively(version, platform, skip_build):
return False
return True
def wait_for_process(process):
buffer = []
if sys.platform != "win32":
os.set_blocking(process.stdout.fileno(), False)
while process.poll() is None:
while True:
line = process.stdout.readline()
if line:
decoded_line = line.decode("UTF-8")
if decoded_line != "":
buffer.append(decoded_line)
else:
break
else:
break
# Read any remaining output
for line in process.stdout:
decoded_line = line.decode("UTF-8")
if decoded_line:
buffer.append(decoded_line)
return buffer
# The first build must call "idf.py build" and consecutive builds must call "idf.py elf" as it finishes faster.
# The problem is that the "idf.py build" always results in an error, even though the elf file is created.
# The solution is to suppress the error if we find that the elf file was created.
def build_first(version, platform, skip_build):
sdk_dir = get_sdk_dir(version, platform)
if verbose:
print(f"Using SDK at {sdk_dir}")
os.environ["TACTILITY_SDK_PATH"] = sdk_dir
sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}")
if not platform.startswith("posix"):
shutil.copy(sdkconfig_path, "sdkconfig")
elf_path = find_elf_file(platform)
# Remove previous elf file: re-creation of the file is used to measure if the build succeeded,
# as the actual build job will always fail due to technical issues with the elf cmake script
if elf_path is not None:
os.remove(elf_path)
if skip_build:
return True
print(f"Building first {platform} build")
cmake_path = get_cmake_path(platform)
print_status_busy(f"Building {platform} ELF")
shell_needed = sys.platform == "win32"
build_command = ["idf.py", "-B", cmake_path, "build"]
if verbose:
print(f"Running command: {' '.join(build_command)}")
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process:
build_output = wait_for_process(process)
# The return code is never expected to be 0 due to a bug in the elf cmake script, but we keep it just in case
if process.returncode == 0:
print(f"{shell_color_green}Building for {platform} ✅{shell_color_reset}")
return True
else:
if find_elf_file(platform) is None:
for line in build_output:
print(line, end="")
print_status_error(f"Building {platform} ELF")
return False
else:
print_status_success(f"Building {platform} ELF")
return True
def build_consecutively(version, platform, skip_build):
sdk_dir = get_sdk_dir(version, platform)
if verbose:
print(f"Using SDK at {sdk_dir}")
os.environ["TACTILITY_SDK_PATH"] = sdk_dir
sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}")
shutil.copy(sdkconfig_path, "sdkconfig")
if skip_build:
return True
cmake_path = get_cmake_path(platform)
print_status_busy(f"Building {platform} ELF")
shell_needed = sys.platform == "win32"
build_command = ["idf.py", "-B", cmake_path, "elf"]
if verbose:
print(f"Running command: {' '.join(build_command)}")
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process:
build_output = wait_for_process(process)
if process.returncode == 0:
print_status_success(f"Building {platform} ELF")
return True
else:
for line in build_output:
print(line, end="")
print_status_error(f"Building {platform} ELF")
return False
#endregion Building
#region Packaging
def package_intermediate_manifest(target_path):
if not os.path.isfile("manifest.properties"):
print_error("manifest.properties not found")
return False
shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties"))
return True
def get_artifact_extension(platform):
# POSIX apps are dlopen()ed shared objects (app-posix-module), not idf.py/elf_loader
# relocatable images, so they land as a plain ".so" instead of ".elf".
return ".so" if platform.startswith("posix") else ".elf"
def package_intermediate_binaries(target_path, platforms, manifest):
# Each binary is built in its own directory (get_binary_dirs()), with its own
# build/cmake-build-{platform} tree; chdir into it so find_elf_file()/get_cmake_path()
# (both CWD-relative) resolve against the right one, then restore CWD for the next binary.
original_cwd = os.getcwd()
for binary, directory in get_binary_dirs(manifest):
os.chdir(directory)
try:
for platform in platforms:
elf_path = find_elf_file(platform)
if elf_path is None:
print_error(f"ELF file not found for '{binary}' on {platform}")
return False
# v2's single app always installs at the fixed path bin/{platform}/app.{elf,so}
# (package_manifest_parse_v2()); v3 apps install under their own declared
# app.N.binary name (package_manifest_parse_v3()).
artifact_name = binary if is_v3_manifest(manifest) else "app"
platform_dir = os.path.join(target_path, "bin", platform)
os.makedirs(platform_dir, exist_ok=True)
shutil.copy(elf_path, os.path.join(platform_dir, f"{artifact_name}{get_artifact_extension(platform)}"))
finally:
os.chdir(original_cwd)
return True
def package_intermediate_assets(target_path):
if os.path.isdir("assets"):
shutil.copytree("assets", os.path.join(target_path, "assets"), dirs_exist_ok=True)
def package_intermediate(platforms, manifest):
target_path = os.path.abspath(os.path.join("build", "package-intermediate"))
if os.path.isdir(target_path):
shutil.rmtree(target_path)
os.makedirs(target_path, exist_ok=True)
if not package_intermediate_manifest(target_path):
return False
if not package_intermediate_binaries(target_path, platforms, manifest):
return False
package_intermediate_assets(target_path)
return True
def package_name(manifest):
return os.path.join("build", f"{get_package_id(manifest)}.app")
def package_all(manifest, platforms):
status = f"Building package with {platforms}"
print_status_busy(status)
if not package_intermediate(platforms, manifest):
print_status_error("Building package failed: missing inputs")
return False
# Create build/something.app
try:
tar_path = package_name(manifest)
with tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT) as tar:
tar.add(os.path.join("build", "package-intermediate"), arcname="")
print_status_success(status)
return True
except Exception as e:
print_status_error(f"Building package failed: {e}")
return False
#endregion Packaging
def setup_environment():
global ttbuild_path
os.makedirs(ttbuild_path, exist_ok=True)
def build_action(manifest, arguments):
platform_arg = arguments.get("platform")
skip_build = arguments.has("skip-build")
platforms_to_build = get_manifest_target_platforms(manifest, platform_arg)
# Environment validation
validate_environment(platforms_to_build)
if use_local_sdk:
global local_base_path
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
validate_local_sdks(platforms_to_build, manifest["target.sdk"])
if should_fetch_sdkconfig_files(platforms_to_build):
fetch_sdkconfig_files(platforms_to_build)
if not use_local_sdk:
sdk_json = read_sdk_json()
validate_self(sdk_json)
# Build
sdk_version = manifest["target.sdk"]
if not use_local_sdk:
if not sdk_download_all(sdk_version, platforms_to_build):
exit_with_error("Failed to download one or more SDKs")
# A multi-binary app builds each of its binaries in its own subdirectory as an independent
# CMake project; a single-binary app has exactly one binary, built at the app root
# ("." from get_binary_dirs()), matching the tool's original single-binary behavior exactly.
original_cwd = os.getcwd()
for binary, directory in get_binary_dirs(manifest):
os.chdir(directory)
try:
ensure_cmakelists_up_to_date(binary if directory == "." else f"{get_package_id(manifest)}.{binary}")
if not build_all(sdk_version, platforms_to_build, skip_build):
return False
finally:
os.chdir(original_cwd)
if not skip_build:
if not package_all(manifest, platforms_to_build):
return False
return True
def clean_action(manifest):
cleaned_any = False
# The app root always has its own build/ (package-intermediate + the final .app/.elf,
# regardless of binary count). A multi-binary app additionally has one build/ per binary
# subdirectory (get_binary_dirs()), which "." (the root, handled above) doesn't repeat.
if os.path.exists("build"):
print_status_busy("Removing build/")
shutil.rmtree("build")
print_status_success("Removed build/")
cleaned_any = True
original_cwd = os.getcwd()
for _, directory in get_binary_dirs(manifest):
if directory == ".":
continue
os.chdir(directory)
try:
if os.path.exists("build"):
print_status_busy(f"Removing {directory}/build/")
shutil.rmtree("build")
print_status_success(f"Removed {directory}/build/")
cleaned_any = True
finally:
os.chdir(original_cwd)
if not cleaned_any:
print("Nothing to clean")
def clear_cache_action():
if os.path.exists(ttbuild_path):
print_status_busy(f"Removing {ttbuild_path}/")
shutil.rmtree(ttbuild_path)
print_status_success(f"Removed {ttbuild_path}/")
else:
print("Nothing to clear")
def update_self_action():
sdk_json = read_sdk_json()
tool_download_url = sdk_json["toolDownloadUrl"]
if download_file(tool_download_url, "tactility.py"):
print("Updated")
else:
exit_with_error("Update failed")
def get_device_info(host):
print_status_busy(f"Requesting device info")
url = get_url(host, "/info")
try:
response = requests.get(url, timeout=http_timeout_seconds)
if response.status_code != 200:
print_error("Run failed")
else:
print_status_success(f"Received device info:")
print(response.json())
except requests.RequestException as e:
print_status_error(f"Device info request failed: {e}")
def run_action(manifest, arguments):
host = arguments.get("host")
if host is None:
exit_with_error("Missing required argument: --host")
app_id = get_primary_app_id(manifest)
print_status_busy("Running")
url = get_url(host, "/app/run")
params = {'id': app_id}
try:
response = requests.post(url, params=params, timeout=http_timeout_seconds)
if response.status_code != 200:
print_error("Run failed")
else:
print_status_success("Running")
except requests.RequestException as e:
print_status_error(f"Running request failed: {e}")
def install_action(manifest, arguments):
host = arguments.get("host")
if host is None:
exit_with_error("Missing required argument: --host")
package_path = package_name(manifest)
if not os.path.isfile(package_path):
print_status_error(f"Package not found: {package_path} (run 'build' first)")
return False
print_status_busy("Installing")
url = get_url(host, "/app/install")
try:
# Prepare multipart form data
with open(package_path, 'rb') as file:
files = {
'app': file
}
response = requests.put(url, files=files, timeout=install_timeout_seconds)
if response.status_code != 200:
print_status_error("Install failed")
return False
else:
print_status_success("Installing")
return True
except requests.RequestException as e:
print_status_error(f"Install request failed: {e}")
return False
except IOError as e:
print_status_error(f"Install file error: {e}")
return False
def uninstall_action(manifest, arguments):
host = arguments.get("host")
if host is None:
exit_with_error("Missing required argument: --host")
app_id = get_package_id(manifest)
print_status_busy("Uninstalling")
url = get_url(host, "/app/uninstall")
params = {'id': app_id}
try:
response = requests.put(url, params=params, timeout=http_timeout_seconds)
if response.status_code != 200:
print_status_error("Server responded that uninstall failed")
else:
print_status_success("Uninstalled")
except requests.RequestException as e:
print_status_error(f"Uninstall request failed: {e}")
#region Main
if __name__ == "__main__":
print(f"Tactility Build System v{ttbuild_version}")
# Anchor the cache to the invocation directory, before --path (below) can chdir into the app.
ttbuild_path = os.path.abspath(ttbuild_path)
argv = sys.argv[1:]
if len(argv) == 0:
print_help()
sys.exit(1)
parsed_command = parse_command_line(argv)
if parsed_command.arguments.has("help"):
print_help()
sys.exit()
if parsed_command.action is None:
print_help()
sys.exit(1)
verbose = parsed_command.arguments.has("verbose")
use_local_sdk = parsed_command.arguments.has("local-sdk")
app_path = parsed_command.arguments.get("path")
if app_path is not None:
if not os.path.isdir(app_path) or not os.path.isfile(os.path.join(app_path, "manifest.properties")):
exit_with_error(f"App path not found or missing manifest.properties: {app_path}")
if verbose:
print_status_success(f"Switching to app directory: {app_path}")
os.chdir(app_path)
# Environment setup
setup_environment()
if not os.path.isfile("manifest.properties"):
exit_with_error("manifest.properties not found")
manifest = read_manifest()
validate_manifest(manifest)
# Update SDK cache (tool.json)
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
exit_with_error("Failed to retrieve SDK info")
# Actions
action_arg = parsed_command.action
if action_arg == "build":
if not build_action(manifest, parsed_command.arguments):
sys.exit(1)
elif action_arg == "clean":
clean_action(manifest)
elif action_arg == "clearcache":
clear_cache_action()
elif action_arg == "updateself":
update_self_action()
elif action_arg == "run":
run_action(manifest, parsed_command.arguments)
elif action_arg == "install":
install_action(manifest, parsed_command.arguments)
elif action_arg == "uninstall":
uninstall_action(manifest, parsed_command.arguments)
elif action_arg == "bir" or action_arg == "brrr":
if build_action(manifest, parsed_command.arguments):
if install_action(manifest, parsed_command.arguments):
run_action(manifest, parsed_command.arguments)
else:
print_help()
exit_with_error("Unknown commandline parameter")
#endregion Main