1040 lines
41 KiB
Python
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
|