Tactility/tactility.py
Ken Van Hoeylandt cba73d1de2
app-module and TactilitySDK improvements (#665)
New features:
- SDK version 7.0.0 packages include version metadata and platform-specific application settings.
- Apps gain POSIX support for file polling, terminal attributes, and standard input/output, plus additional C and POSIX functions.
 - Shell commands can launch installed headless apps using their registered app settings.
- Apps can exit with a status code, and terminal clear commands support clearing from the cursor to the end, from the start through the cursor, or the entire display.

Fixes:
- SDK integration builds locate the build tool reliably and use settings for the selected platform.
- ESP32 file operations handle invalid paths and existing directories more consistently.
2026-09-27 22:30:23 +02:00

1038 lines
41 KiB
Python
Executable File

#!/usr/bin/env python3
# License: Apache License v2
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 = "7.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 get_tool_json_path(version):
return os.path.join(ttbuild_path, version, "tactility.py.json")
def read_sdk_json(version):
json_file_path = get_tool_json_path(version)
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}")
if not sdk_has_sdkconfig(version, platform):
exit_with_error(f"Local SDK for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Rebuild it with the current SDK release scripts.")
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(version):
json_filepath = get_tool_json_path(version)
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(version):
json_filepath = get_tool_json_path(version)
os.makedirs(os.path.dirname(json_filepath), exist_ok=True)
return download_file(get_sdk_url(version, "tactility.py.json"), json_filepath)
def get_sdk_sdkconfig_path(version, platform):
return os.path.join(get_sdk_dir(version, platform), f"sdkconfig.app.{platform}")
# ESP32 SDKs built before the sdkconfig was bundled lack it and can't build apps. POSIX SDKs never need one.
def sdk_has_sdkconfig(version, platform):
return platform.startswith("posix") or os.path.isfile(get_sdk_sdkconfig_path(version, platform))
def copy_sdk_sdkconfig(version, platform):
if platform.startswith("posix"):
return
sdkconfig_path = get_sdk_sdkconfig_path(version, platform)
if not os.path.isfile(sdkconfig_path):
exit_with_error(f"SDK does not contain {sdkconfig_path}")
shutil.copy(sdkconfig_path, "sdkconfig")
#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 "version" in sdk_json:
exit_with_error("Server returned invalid SDK data format (version not found)")
tool_version = sdk_json["version"]
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.")
#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 sdk_exists(version, platform) and not sdk_has_sdkconfig(version, platform):
print_warning(f"Cached SDK version {version} for {platform} is outdated (no sdkconfig.app.{platform}), downloading it again")
shutil.rmtree(get_sdk_root_dir(version, platform))
if not sdk_exists(version, platform):
if not sdk_download(version, platform):
return False
if not sdk_has_sdkconfig(version, platform):
print_error(f"SDK version {version} for {platform} does not contain sdkconfig.app.{platform}, which this tool requires. Use a newer SDK version.")
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
copy_sdk_sdkconfig(version, platform)
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
copy_sdk_sdkconfig(version, platform)
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 not use_local_sdk:
sdk_json = read_sdk_json(manifest["target.sdk"])
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(manifest):
if download_file(get_sdk_url(manifest["target.sdk"], "tactility.py"), "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)
# Same for a relative local SDK path.
if os.environ.get("TACTILITY_SDK_PATH") is not None:
os.environ["TACTILITY_SDK_PATH"] = os.path.abspath(os.environ["TACTILITY_SDK_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 (tactility.py.json)
sdk_version = manifest["target.sdk"]
if not use_local_sdk and should_update_tool_json(sdk_version) and not update_tool_json(sdk_version):
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(manifest)
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