diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index bd82eca..0000000 --- a/.gitattributes +++ /dev/null @@ -1,4 +0,0 @@ -* text=auto eol=lf -*.bat eol=crlf -*.ps1 eol=crlf -*.cmd eol=crlf diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100755 index 0000000..4ae5478 --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,34 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.8' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 85e9a27..11f05fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,7 @@ -# State dir -.ufbt - -# Created by https://www.toptal.com/developers/gitignore/api/python,scons,c++,visualstudiocode,c -# Edit at https://www.toptal.com/developers/gitignore?templates=python,scons,c++,visualstudiocode,c - ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] -*$py.class - -# C extensions # Distribution / packaging .Python @@ -32,87 +23,10 @@ share/python-wheels/ *.egg MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# mkdocs documentation -/site - - ### SCons ### # for projects that use SCons for building: http://http://www.scons.org/ .sconsign.dblite -# When configure fails, SCons outputs these -config.log -.sconf_temp - ### VisualStudioCode ### .vscode/* !.vscode/settings.json @@ -123,13 +37,3 @@ config.log # Local History for Visual Studio Code .history/ - -# Built Visual Studio Code Extensions -*.vsix - -### VisualStudioCode Patch ### -# Ignore all local history of files -.history -.ionide - -# End of https://www.toptal.com/developers/gitignore/api/python,scons,c++,visualstudiocode,c \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..23fd35f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true +} \ No newline at end of file diff --git a/README.md b/README.md index 227bbe8..015e025 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,17 @@ # uFBT - micro Flipper Build Tool -uFBT is a tool for building applications for Flipper Zero. It is a simplified version of [Flipper Build Tool (FBT)](https://github.com/flipperdevices/flipperzero-firmware/blob/dev/documentation/fbt.md). - -uFBT allows you to perform basic development tasks for Flipper Zero, like building and debugging applications, flashing firmware. It uses prebuilt binaries and libraries, so you don't need to build the whole firmware to compile and debug your application. +uFBT is a cross-platform tool for building applications for [Flipper Zero](https://flipperzero.one/). It is a simplified version of [Flipper Build Tool (FBT)](https://github.com/flipperdevices/flipperzero-firmware/blob/dev/documentation/fbt.md). +uFBT enables basic development tasks for Flipper Zero, such as building and debugging applications, flashing firmware, creating VSCode development configurations. It uses prebuilt binaries and libraries, so you don't need to build [the whole firmware](https://github.com/flipperdevices/flipperzero-firmware) to compile and debug your application for Flipper. ## Installation -Clone this repository and add its path to your `PATH` environment variable. On first run, uFBT will download and install all required SDK components from `dev` branch of official firmware. - -### Updating the SDK +- **Linux & macOS**: `python3 -m pip install --upgrade ufbt` +- **Windows**: `py -m pip install --upgrade ufbt` -To update the SDK, run `ufbt update`. This will download and install all required SDK components from previously used channel or branch. +uFBT uses your system's Python for running bootstrap code. Minimal supported version is **Python 3.8**. For executing actual build tasks, uFBT will download and use its own Python binaries and a toolchain built for your platform. -To switch to a different version of the SDK, run `ufbt update --channel=[dev|rc|release]`. Or you can use any not-yet-merged branch from official repo, like `ufbt update --branch=feature/my-awesome-feature`. - -If something goes wrong and uFBT state becomes corrupted, you can reset it by running `ufbt purge`. If that doesn't work, you can try removing `.ufbt` subfolder manually from ufbt's folder. +On first run, uFBT will download and install required SDK components from `release` update channel of official firmware. For more information on how to switch to a different version of the SDK, see [Managing the SDK](#managing-the-sdk) section. ## Usage @@ -23,7 +19,9 @@ If something goes wrong and uFBT state becomes corrupted, you can reset it by ru Run `ufbt` in the root directory of your application (the one with `application.fam` file in it). It will build your application and place the resulting binary in `dist` subdirectory. -You can upload and start your application on Flipper attached over USB using `ufbt launch`. +You can upload and start your application on Flipper attached over USB using `ufbt launch`. + +To see other available commands and options, run `ufbt -h`. ### Debugging @@ -38,9 +36,34 @@ uFBT provides a configuration for VSCode that allows you to build and debug your ### Application template uFBT can create a template for your application. To do this, run `ufbt create APPID=` in the directory where you want to create your application. It will create an application manifest and its main source file. You can then build and debug your application using the instructions above. + Application manifests are explained in the [FBT documentation](https://github.com/flipperdevices/flipperzero-firmware/blob/dev/documentation/AppManifests.md). ### Other * `ufbt cli` starts a CLI session with the device; * `ufbt lint`, `ufbt format` run clang-format on application's sources. + +## Managing the SDK + +To update the SDK, run `ufbt update`. This will download and install all required SDK components from previously used source. + +- To switch to SDK for a different **release channel**, run `ufbt update --channel=[dev|rc|release]`. + - uFBT also supports 3rd-party update indexers, following the same schema as [official firmware](https://github.com/flipperdevices/flipperzero-firmware). To use them, run `ufbt update --index-url=`, where `` is a URL to the index file, e.g. `https://update.flipperzero.one/firmware/directory.json`. +- To use SDK for a **certain release** or a not-yet-merged **branch** from official repo, run `ufbt update --branch=0.81.1` or `ufbt update --branch=owner/my-awesome-feature`. + - You can also use branches from other repos, where build artifacts are available from an indexed directory, by specifying `--index-url=`. +- uFBT can also download and update the SDK from any **fixed URL**. To do this, run `ufbt update --url=`. +- To use a **local copy** of the SDK, run `ufbt update --local=`. This will use the SDK located in `` instead of downloading it. Useful for testing local builds of the SDK. + +uFBT stores its state in `.ufbt` subfolder in your home directory. You can override this location by setting `UFBT_HOME` environment variable. + + +### ufbt-bootstrap + +Updating the SDK is handled by uFBT component called _bootstrap_. It has a dedicated entry point, `ufbt-bootstrap`, with additional options that might be useful in certain scenarios. Run `ufbt-bootstrap --help` to see them. + +## Troubleshooting + +If something goes wrong and uFBT state becomes corrupted, you can reset it by running `ufbt clean`. If that doesn't work, you can try removing `.ufbt` subfolder manually from your home folder. + +`ufbt-bootstrap` and SDK-related `ufbt` subcommands accept `--verbose` option that will print additional debug information. diff --git a/SConstruct b/SConstruct deleted file mode 100644 index 9a7d16b..0000000 --- a/SConstruct +++ /dev/null @@ -1,383 +0,0 @@ -from SCons.Platform import TempFileMunge -from SCons.Node import FS - -import os -import multiprocessing -import pathlib - - -DefaultEnvironment(tools=[]) - -EnsurePythonVersion(3, 8) - -SetOption("num_jobs", multiprocessing.cpu_count()) -SetOption("max_drift", 1) -# SetOption("silent", False) - - -ufbt_variables = SConscript("site_scons/commandline.scons") - -forward_os_env = { - # Import PATH from OS env - scons doesn't do that by default - "PATH": os.environ["PATH"], -} - -# Proxying environment to child processes & scripts -variables_to_forward = [ - # CI/CD variables - "WORKFLOW_BRANCH_OR_TAG", - "DIST_SUFFIX", - # Python & other tools - "HOME", - "APPDATA", - "PYTHONHOME", - "PYTHONNOUSERSITE", - "TMP", - "TEMP", - # Colors for tools - "TERM", -] - -if proxy_env := GetOption("proxy_env"): - variables_to_forward.extend(proxy_env.split(",")) - -for env_value_name in variables_to_forward: - if environ_value := os.environ.get(env_value_name, None): - forward_os_env[env_value_name] = environ_value - -# Core environment init - loads SDK state, sets up paths, etc. -core_env = Environment( - variables=ufbt_variables, - ENV=forward_os_env, - tools=[ - "ufbt_state", - ("ufbt_help", {"vars": ufbt_variables}), - ], -) - - -if "update" in BUILD_TARGETS: - SConscript( - "site_scons/update.scons", - exports={"core_env": core_env}, - ) - -if "purge" in BUILD_TARGETS: - core_env.Execute(Delete(".ufbt")) - print("uFBT state purged") - Exit(0) - -# Now we can import stuff bundled with SDK - it was added to sys.path by ufbt_state - -from fbt.util import ( - tempfile_arg_esc_func, - single_quote, - extract_abs_dir, - extract_abs_dir_path, - wrap_tempfile, - path_as_posix, -) -from fbt.appmanifest import FlipperAppType -from fbt.sdk.cache import SdkCache - -# Base environment with all tools loaded from SDK -env = core_env.Clone( - toolpath=[core_env["FBT_SCRIPT_DIR"].Dir("fbt_tools")], - tools=[ - "fbt_tweaks", - ( - "crosscc", - { - "toolchain_prefix": "arm-none-eabi-", - "versions": (" 10.3",), - }, - ), - "fwbin", - "python3", - "sconsrecursiveglob", - "sconsmodular", - "ccache", - "fbt_apps", - "fbt_extapps", - "fbt_assets", - ("compilation_db", {"COMPILATIONDB_COMSTR": "\tCDB\t${TARGET}"}), - ], - FBT_FAP_DEBUG_ELF_ROOT=Dir("#.ufbt/build"), - TEMPFILE=TempFileMunge, - MAXLINELENGTH=2048, - PROGSUFFIX=".elf", - TEMPFILEARGESCFUNC=tempfile_arg_esc_func, - SINGLEQUOTEFUNC=single_quote, - ABSPATHGETTERFUNC=extract_abs_dir_path, - APPS=[], - UFBT_API_VERSION=SdkCache( - core_env.subst("$SDK_DEFINITION"), load_version_only=True - ).version, - APPCHECK_COMSTR="\tAPPCHK\t${SOURCE}\n\t\tTarget: ${TARGET_HW}, API: ${UFBT_API_VERSION}", -) - -wrap_tempfile(env, "LINKCOM") -wrap_tempfile(env, "ARCOM") - -# print(env.Dump()) - -# Dist env - -dist_env = env.Clone( - tools=[ - "fbt_dist", - "fbt_debugopts", - "openocd", - "blackmagic", - "jflash", - "textfile", - ], - ENV=os.environ, - OPENOCD_OPTS=[ - "-f", - "interface/stlink.cfg", - "-c", - "transport select hla_swd", - "-f", - "${FBT_DEBUG_DIR}/stm32wbx.cfg", - "-c", - "stm32wbx.cpu configure -rtos auto", - ], -) - -openocd_target = dist_env.OpenOCDFlash( - dist_env["UFBT_STATE_DIR"].File("flash"), - dist_env["FW_BIN"], - OPENOCD_COMMAND=[ - "-c", - "program ${SOURCE.posix} reset exit 0x08000000", - ], -) -dist_env.Alias("firmware_flash", openocd_target) -dist_env.Alias("flash", openocd_target) -if env["FORCE"]: - env.AlwaysBuild(openocd_target) - -firmware_debug = dist_env.PhonyTarget( - "debug", - "${GDBPYCOM}", - source=dist_env["FW_ELF"], - GDBOPTS="${GDBOPTS_BASE}", - GDBREMOTE="${OPENOCD_GDB_PIPE}", - FBT_FAP_DEBUG_ELF_ROOT=path_as_posix(dist_env.subst("$FBT_FAP_DEBUG_ELF_ROOT")), -) - -dist_env.PhonyTarget( - "blackmagic", - "${GDBPYCOM}", - source=dist_env["FW_ELF"], - GDBOPTS="${GDBOPTS_BASE} ${GDBOPTS_BLACKMAGIC}", - GDBREMOTE="${BLACKMAGIC_ADDR}", - FBT_FAP_DEBUG_ELF_ROOT=path_as_posix(dist_env.subst("$FBT_FAP_DEBUG_ELF_ROOT")), -) - -dist_env.PhonyTarget( - "flash_blackmagic", - "$GDB $GDBOPTS $SOURCES $GDBFLASH", - source=dist_env["FW_ELF"], - GDBOPTS="${GDBOPTS_BASE} ${GDBOPTS_BLACKMAGIC}", - GDBREMOTE="${BLACKMAGIC_ADDR}", - GDBFLASH=[ - "-ex", - "load", - "-ex", - "quit", - ], -) - -flash_usb_full = dist_env.UsbInstall( - dist_env["UFBT_STATE_DIR"].File("usbinstall"), - [], -) -dist_env.AlwaysBuild(flash_usb_full) -dist_env.Alias("flash_usb", flash_usb_full) -dist_env.Alias("flash_usb_full", flash_usb_full) - -# App build environment - -appenv = env.Clone( - CCCOM=env["CCCOM"].replace("$CFLAGS", "$CFLAGS_APP $CFLAGS"), - CXXCOM=env["CXXCOM"].replace("$CXXFLAGS", "$CXXFLAGS_APP $CXXFLAGS"), - LINKCOM=env["LINKCOM"].replace("$LINKFLAGS", "$LINKFLAGS_APP $LINKFLAGS"), - COMPILATIONDB_USE_ABSPATH=True, -) - - -original_app_dir = Dir(appenv.subst("$UFBT_APP_DIR")) -app_mount_point = Dir("#/app/") -app_mount_point.addRepository(original_app_dir) - -appenv.LoadAppManifest(app_mount_point) -appenv.PrepareApplicationsBuild() - -# print(appenv["APPMGR"].known_apps) - -####################### - -extapps = appenv["EXT_APPS"] - -apps_to_build_as_faps = [ - FlipperAppType.PLUGIN, - FlipperAppType.EXTERNAL, -] - -known_extapps = [ - app - for apptype in apps_to_build_as_faps - for app in appenv["APPBUILD"].get_apps_of_type(apptype, True) -] -# print(f"Known external apps: {known_extapps}") - -for app in known_extapps: - app_artifacts = appenv.BuildAppElf(app) - app_src_dir = extract_abs_dir(app_artifacts.app._appdir) - app_artifacts.installer = [ - appenv.Install(app_src_dir.Dir("dist"), app_artifacts.compact), - appenv.Install(app_src_dir.Dir("dist").Dir("debug"), app_artifacts.debug), - ] - -if appenv["FORCE"]: - appenv.AlwaysBuild([extapp.compact for extapp in extapps.values()]) - -# Final steps - target aliases - -install_and_check = [ - (extapp.installer, extapp.validator) for extapp in extapps.values() -] -Alias( - "faps", - install_and_check, -) -Default(install_and_check) - -# Compilation database - -fwcdb = appenv.CompilationDatabase( - original_app_dir.Dir(".vscode").File("compile_commands.json") -) - -AlwaysBuild(fwcdb) -Precious(fwcdb) -NoClean(fwcdb) -if len(extapps): - Default(fwcdb) - - -# launch handler - -app_artifacts = None -if len(extapps) == 1: - app_artifacts = list(extapps.values())[0] -elif len(extapps) > 1: # more than 1 app - try to find one with matching id - if appsrc := appenv.subst("$APPID"): - app_artifacts = appenv.GetExtAppFromPath(appsrc) - -if app_artifacts: - appenv.PhonyTarget( - "launch", - '${PYTHON3} "${APP_RUN_SCRIPT}" "${SOURCE}" --fap_dst_dir "/ext/apps/${FAP_CATEGORY}"', - source=app_artifacts.compact, - FAP_CATEGORY=app_artifacts.app.fap_category, - ) - appenv.Alias("launch", app_artifacts.validator) - -# cli handler - -appenv.PhonyTarget( - "cli", - '${PYTHON3} "${FBT_SCRIPT_DIR}/serial_cli.py"', -) - -# Linter - -dist_env.PhonyTarget( - "lint", - "${PYTHON3} ${FBT_SCRIPT_DIR}/lint.py check ${LINT_SOURCES}", - source=original_app_dir.File(".clang-format"), - LINT_SOURCES=[original_app_dir], -) - -dist_env.PhonyTarget( - "format", - "${PYTHON3} ${FBT_SCRIPT_DIR}/lint.py format ${LINT_SOURCES}", - source=original_app_dir.File(".clang-format"), - LINT_SOURCES=[original_app_dir], -) - - -# Prepare vscode environment -def _path_as_posix(path): - return pathlib.Path(path).as_posix() - - -vscode_dist = [] -for template_file in dist_env.Glob("#project_template/.vscode/*"): - vscode_dist.append( - dist_env.Substfile( - original_app_dir.Dir(".vscode").File(template_file.name), - template_file, - SUBST_DICT={ - "@UFBT_VSCODE_PATH_SEP@": os.path.pathsep, - "@UFBT_TOOLCHAIN_ARM_TOOLCHAIN_DIR@": pathlib.Path( - dist_env.WhereIs("arm-none-eabi-gcc") - ).parent.as_posix(), - "@UFBT_TOOLCHAIN_GCC@": _path_as_posix( - dist_env.WhereIs("arm-none-eabi-gcc") - ), - "@UFBT_TOOLCHAIN_GDB_PY@": _path_as_posix( - dist_env.WhereIs("arm-none-eabi-gdb-py") - ), - "@UFBT_TOOLCHAIN_OPENOCD@": _path_as_posix(dist_env.WhereIs("openocd")), - "@UFBT_APP_DIR@": _path_as_posix(original_app_dir.abspath), - "@UFBT_ROOT_DIR@": _path_as_posix(Dir("#").abspath), - "@UFBT_DEBUG_DIR@": dist_env["FBT_DEBUG_DIR"], - "@UFBT_DEBUG_ELF_DIR@": _path_as_posix( - dist_env["FBT_FAP_DEBUG_ELF_ROOT"].abspath - ), - "@UFBT_FIRMWARE_ELF@": _path_as_posix(dist_env["FW_ELF"].abspath), - }, - ) - ) - -for config_file in dist_env.Glob("#/project_template/.*"): - if isinstance(config_file, FS.Dir): - continue - vscode_dist.append(dist_env.Install(original_app_dir, config_file)) - -dist_env.Precious(vscode_dist) -dist_env.NoClean(vscode_dist) -dist_env.Alias("vscode_dist", vscode_dist) - - -# Creating app from base template - -dist_env.SetDefault(FBT_APPID=appenv.subst("$APPID") or "template") -app_template_dist = [] -for template_file in dist_env.Glob("#project_template/app_template/*"): - dist_file_name = dist_env.subst(template_file.name) - if template_file.name.endswith(".png"): - app_template_dist.append( - dist_env.InstallAs(original_app_dir.File(dist_file_name), template_file) - ) - else: - app_template_dist.append( - dist_env.Substfile( - original_app_dir.File(dist_file_name), - template_file, - SUBST_DICT={ - "@FBT_APPID@": dist_env.subst("$FBT_APPID"), - }, - ) - ) -AddPostAction( - app_template_dist[-1], - Mkdir(original_app_dir.Dir("images")), -) -dist_env.Precious(app_template_dist) -dist_env.NoClean(app_template_dist) -dist_env.Alias("create", app_template_dist) diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +0.2.0 diff --git a/bootstrap.py b/bootstrap.py deleted file mode 100644 index 68e1bf2..0000000 --- a/bootstrap.py +++ /dev/null @@ -1,360 +0,0 @@ -import os -import enum -import json -import re -import shutil -import ssl -import tarfile -import argparse - -from zipfile import ZipFile - -from pathlib import PurePosixPath, Path -from pathlib import Path -from urllib.parse import unquote, urlparse -from urllib.request import urlopen -from html.parser import HTMLParser - -# Setup logging -import logging - -logging.basicConfig( - format="%(asctime)s [%(levelname)s] %(message)s", - level=logging.INFO, - datefmt="%Y-%m-%d %H:%M:%S", -) -log = logging.getLogger(__name__) - -# Temporary fix for SSL negotiation failure on Mac -_ssl_context = ssl.create_default_context() -_ssl_context.check_hostname = False -_ssl_context.verify_mode = ssl.CERT_NONE - - -class FileType(enum.Enum): - SDK_ZIP = "sdk_zip" - LIB_ZIP = "lib_zip" - CORE2_FIRMWARE_TGZ = "core2_firmware_tgz" - RESOURCES_TGZ = "resources_tgz" - SCRIPTS_TGZ = "scripts_tgz" - UPDATE_TGZ = "update_tgz" - FIRMWARE_ELF = "firmware_elf" - FULL_BIN = "full_bin" - FULL_DFU = "full_dfu" - FULL_JSON = "full_json" - UPDATER_BIN = "updater_bin" - UPDATER_DFU = "updater_dfu" - UPDATER_ELF = "updater_elf" - UPDATER_JSON = "updater_json" - - -class BaseSdkLoader: - class SdkEntry(enum.Enum): - SDK = "sdk" - SCRIPTS = "scripts" - LIB = "lib" - FW_ELF = "fwelf" - FW_BIN = "fwbin" - FW_BUNDLE = "fwbundle" - - ANY_TARGET_FILE_TYPES = ( - FileType.CORE2_FIRMWARE_TGZ, - FileType.SCRIPTS_TGZ, - FileType.RESOURCES_TGZ, - ) - - ENTRY_TO_FILE_TYPE = { - SdkEntry.SDK: FileType.SDK_ZIP, - SdkEntry.SCRIPTS: FileType.SCRIPTS_TGZ, - SdkEntry.LIB: FileType.LIB_ZIP, - SdkEntry.FW_ELF: FileType.FIRMWARE_ELF, - SdkEntry.FW_BIN: FileType.FULL_BIN, - SdkEntry.FW_BUNDLE: FileType.UPDATE_TGZ, - } - - def __init__(self, download_dir: str): - self._download_dir = download_dir - - # Returns local FS path. Downloads file if necessary - def get_sdk_component(self, entry: SdkEntry, target: str): - raise NotImplementedError() - - def get_metadata(self): - raise NotImplementedError() - - def _fixup_target_type(self, file_type: FileType, target: str) -> str: - return "any" if file_type in self.ANY_TARGET_FILE_TYPES else target - - def _fetch_file(self, url: str): - log.debug(f"Fetching {url}") - file_name = PurePosixPath(unquote(urlparse(url).path)).parts[-1] - file_path = os.path.join(self._download_dir, file_name) - - os.makedirs(self._download_dir, exist_ok=True) - - with urlopen(url, context=_ssl_context) as response, open( - file_path, "wb" - ) as out_file: - data = response.read() - out_file.write(data) - - return file_path - - -class BranchSdkLoader(BaseSdkLoader): - class LinkExtractor(HTMLParser): - FILE_NAME_RE = re.compile(r"flipper-z-(\w+)-(\w+)-(.+)\.(\w+)") - - def reset(self): - super().reset() - self.files = {} - self.version = None - - def handle_starttag(self, tag, attrs): - if tag == "a" and (href := dict(attrs).get("href", None)): - # .map files have special naming and we don't need them - if ".map" in href: - return - if match := self.FILE_NAME_RE.match(href): - target, file_type, version, ext = match.groups() - file_type_str = f"{file_type}_{ext}".upper() - if file_type := FileType._member_map_.get(file_type_str, None): - self.files[(file_type, target)] = href - if not self.version: - self.version = version - elif not version.startswith(self.version): - raise RuntimeError( - f"Found multiple versions: {self.version} and {version}" - ) - - def __init__(self, branch: str, download_dir: str): - super().__init__(download_dir) - self._branch = branch - self._branch_url = f"https://update.flipperzero.one/builds/firmware/{branch}/" - self._branch_files = {} - self._version = None - self._fetch_branch() - - def _fetch_branch(self): - # Fetch html index page with links to files - log.info(f"Fetching branch index {self._branch_url}") - with urlopen(self._branch_url, context=_ssl_context) as response: - html = response.read().decode("utf-8") - extractor = BranchSdkLoader.LinkExtractor() - extractor.feed(html) - self._branch_files = extractor.files - self._version = extractor.version - log.info(f"Found version {self._version}") - - def get_metadata(self): - return { - "mode": "branch", - "branch": self._branch, - "version": self._version, - } - - def get_sdk_component(self, entry: BaseSdkLoader.SdkEntry, target: str): - file_type = self.ENTRY_TO_FILE_TYPE[entry] - target = self._fixup_target_type(self.ENTRY_TO_FILE_TYPE[entry], target) - if not (file_name := self._branch_files.get((file_type, target), None)): - raise ValueError(f"File not found for {entry} {target}") - - return self._fetch_file(self._branch_url + file_name) - - -class UpdateChannelSdkLoader(BaseSdkLoader): - class UpdateChannel(enum.Enum): - DEV = "development" - RC = "release-candidate" - RELEASE = "release" - - def __init__(self, channel: UpdateChannel, download_dir: str): - super().__init__(download_dir) - self.channel = channel - self.version_info = self._fetch_version(self.channel) - - def get_sdk_component(self, entry: BaseSdkLoader.SdkEntry, target: str): - file_type = self.ENTRY_TO_FILE_TYPE[entry] - target = self._fixup_target_type(file_type, target) - - file_info = self._get_file_info(self.version_info, file_type, target) - if not (file_url := file_info.get("url", None)): - raise ValueError(f"Invalid file url") - - return self._fetch_file(file_url) - - def get_metadata(self): - return { - "mode": "channel", - "channel": self.channel.name.lower(), - "version": self.version_info["version"], - } - - @staticmethod - def _fetch_version(channel: UpdateChannel): - log.info(f"Fetching version info for {channel}") - url = "https://update.flipperzero.one/firmware/directory.json" - data = json.loads(urlopen(url, context=_ssl_context).read().decode("utf-8")) - - channels = data.get("channels", []) - if not channels: - raise ValueError(f"Invalid channel: {channel}") - - channel_data = next((c for c in channels if c["id"] == channel.value), None) - if not channel_data: - raise ValueError(f"Invalid channel: {channel}") - - versions = channel_data.get("versions", []) - if not versions: - raise ValueError(f"Empty channel: {channel}") - - log.info(f"Using version: {versions[0]['version']}") - return versions[0] - - @staticmethod - def _get_file_info(version_data: dict, file_type: FileType, file_target: str): - files = version_data.get("files", []) - if not files: - raise ValueError(f"Empty files list") - - file_info = next( - ( - f - for f in files - if f["type"] == file_type.value and f["target"] == file_target - ), - None, - ) - if not file_info: - raise ValueError(f"Invalid file type: {file_type}") - - return file_info - - -def deploy_sdk(target_dir: str, sdk_loader: BaseSdkLoader, hw_target: str, force: bool): - SDK_STATE_FILE_NAME = "sdk_state.json" - - sdk_layout = { - BaseSdkLoader.SdkEntry.SDK: ("sdk", None), - BaseSdkLoader.SdkEntry.SCRIPTS: (".", None), - BaseSdkLoader.SdkEntry.LIB: ("lib", None), - BaseSdkLoader.SdkEntry.FW_ELF: ("firmware.elf", None), - BaseSdkLoader.SdkEntry.FW_BIN: ("firmware.bin", None), - BaseSdkLoader.SdkEntry.FW_BUNDLE: ( - ".", - lambda s: os.path.splitext(os.path.basename(s))[0].replace( - "flipper-z-", "" # ugly - ), - ), - } - - log.info(f"uFBT state dir: {target_dir}") - if not force and os.path.exists(target_dir): - # Read existing state - with open(os.path.join(target_dir, SDK_STATE_FILE_NAME), "r") as f: - sdk_state = json.load(f) - # Check if we need to update - if ( - sdk_state.get("meta", {}).get("version") - == sdk_loader.get_metadata().get("version") - and sdk_state.get("meta", {}).get("hw_target") == hw_target - ): - log.info("SDK is up-to-date") - return - - shutil.rmtree(target_dir, ignore_errors=True) - - sdk_state = { - "meta": {"hw_target": hw_target, **sdk_loader.get_metadata()}, - "components": {}, - } - for entry, (entry_dir, entry_path_converter) in sdk_layout.items(): - log.info(f"Deploying {entry} to {entry_dir}") - sdk_component_path = sdk_loader.get_sdk_component(entry, hw_target) - component_dst_path = os.path.join(target_dir, entry_dir) - if sdk_component_path.endswith(".zip"): - with ZipFile(sdk_component_path, "r") as zip_file: - zip_file.extractall(component_dst_path) - elif sdk_component_path.endswith(".tgz"): - with tarfile.open(sdk_component_path, "r:gz") as tar_file: - tar_file.extractall(component_dst_path) - else: - shutil.copy2(sdk_component_path, component_dst_path) - - if entry_path_converter: - component_meta_path = entry_path_converter(sdk_component_path) - else: - component_meta_path = os.path.relpath(component_dst_path, target_dir) - - sdk_state["components"][entry.value] = component_meta_path - - with open( - os.path.join(target_dir, SDK_STATE_FILE_NAME), - "w", - ) as f: - json.dump(sdk_state, f, indent=4) - log.info("SDK deployed.") - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument( - "--branch", - "-b", - help="Branch to use", - ) - parser.add_argument( - "--channel", - "-c", - help="Update channel to use", - choices=list( - map( - lambda s: s.lower(), - UpdateChannelSdkLoader.UpdateChannel.__members__.keys(), - ) - ), - ) - parser.add_argument( - "--hw-target", - "-t", - help="Hardware target", - default="f7", - ) - parser.add_argument( - "--ufbt-dir", - "-d", - help="uFBT state directory", - default=".ufbt", - ) - # Force flag - parser.add_argument( - "--force", - "-f", - help="Force download", - action="store_true", - default=False, - ) - args = parser.parse_args() - - ufbt_work_dir = Path(args.ufbt_dir) - ufbt_download_dir = ufbt_work_dir / "download" - ufbt_state_dir = ufbt_work_dir / "current" - - if args.branch and args.channel: - parser.error("Only one of --branch and --channel can be specified") - - if args.branch: - sdk_loader = BranchSdkLoader(args.branch, ufbt_download_dir) - elif args.channel: - sdk_loader = UpdateChannelSdkLoader( - UpdateChannelSdkLoader.UpdateChannel[args.channel.upper()], - ufbt_download_dir, - ) - else: - parser.error("One of --branch or --channel must be specified") - - deploy_sdk(ufbt_state_dir.absolute(), sdk_loader, args.hw_target, args.force) - - -if __name__ == "__main__": - main() diff --git a/project_template/.clang-format b/project_template/.clang-format deleted file mode 100644 index 4b76f7f..0000000 --- a/project_template/.clang-format +++ /dev/null @@ -1,191 +0,0 @@ ---- -Language: Cpp -AccessModifierOffset: -4 -AlignAfterOpenBracket: AlwaysBreak -AlignArrayOfStructures: None -AlignConsecutiveMacros: None -AlignConsecutiveAssignments: None -AlignConsecutiveBitFields: None -AlignConsecutiveDeclarations: None -AlignEscapedNewlines: Left -AlignOperands: Align -AlignTrailingComments: false -AllowAllArgumentsOnNextLine: true -AllowAllParametersOfDeclarationOnNextLine: false -AllowShortEnumsOnASingleLine: true -AllowShortBlocksOnASingleLine: Never -AllowShortCaseLabelsOnASingleLine: false -AllowShortFunctionsOnASingleLine: None -AllowShortLambdasOnASingleLine: All -AllowShortIfStatementsOnASingleLine: WithoutElse -AllowShortLoopsOnASingleLine: true -AlwaysBreakAfterDefinitionReturnType: None -AlwaysBreakAfterReturnType: None -AlwaysBreakBeforeMultilineStrings: false -AlwaysBreakTemplateDeclarations: Yes -AttributeMacros: - - __capability -BinPackArguments: false -BinPackParameters: false -BraceWrapping: - AfterCaseLabel: false - AfterClass: false - AfterControlStatement: Never - AfterEnum: false - AfterFunction: false - AfterNamespace: false - AfterObjCDeclaration: false - AfterStruct: false - AfterUnion: false - AfterExternBlock: false - BeforeCatch: false - BeforeElse: false - BeforeLambdaBody: false - BeforeWhile: false - IndentBraces: false - SplitEmptyFunction: true - SplitEmptyRecord: true - SplitEmptyNamespace: true -BreakBeforeBinaryOperators: None -BreakBeforeConceptDeclarations: true -BreakBeforeBraces: Attach -BreakBeforeInheritanceComma: false -BreakInheritanceList: BeforeColon -BreakBeforeTernaryOperators: false -BreakConstructorInitializersBeforeComma: false -BreakConstructorInitializers: BeforeComma -BreakAfterJavaFieldAnnotations: false -BreakStringLiterals: false -ColumnLimit: 99 -CommentPragmas: '^ IWYU pragma:' -QualifierAlignment: Leave -CompactNamespaces: false -ConstructorInitializerIndentWidth: 4 -ContinuationIndentWidth: 4 -Cpp11BracedListStyle: true -DeriveLineEnding: true -DerivePointerAlignment: false -DisableFormat: false -EmptyLineAfterAccessModifier: Never -EmptyLineBeforeAccessModifier: LogicalBlock -ExperimentalAutoDetectBinPacking: false -PackConstructorInitializers: BinPack -BasedOnStyle: '' -ConstructorInitializerAllOnOneLineOrOnePerLine: false -AllowAllConstructorInitializersOnNextLine: true -FixNamespaceComments: false -ForEachMacros: - - foreach - - Q_FOREACH - - BOOST_FOREACH -IfMacros: - - KJ_IF_MAYBE -IncludeBlocks: Preserve -IncludeCategories: - - Regex: '.*' - Priority: 1 - SortPriority: 0 - CaseSensitive: false - - Regex: '^(<|"(gtest|gmock|isl|json)/)' - Priority: 3 - SortPriority: 0 - CaseSensitive: false - - Regex: '.*' - Priority: 1 - SortPriority: 0 - CaseSensitive: false -IncludeIsMainRegex: '(Test)?$' -IncludeIsMainSourceRegex: '' -IndentAccessModifiers: false -IndentCaseLabels: false -IndentCaseBlocks: false -IndentGotoLabels: true -IndentPPDirectives: None -IndentExternBlock: AfterExternBlock -IndentRequires: false -IndentWidth: 4 -IndentWrappedFunctionNames: true -InsertTrailingCommas: None -JavaScriptQuotes: Leave -JavaScriptWrapImports: true -KeepEmptyLinesAtTheStartOfBlocks: false -LambdaBodyIndentation: Signature -MacroBlockBegin: '' -MacroBlockEnd: '' -MaxEmptyLinesToKeep: 1 -NamespaceIndentation: None -ObjCBinPackProtocolList: Auto -ObjCBlockIndentWidth: 4 -ObjCBreakBeforeNestedBlockParam: true -ObjCSpaceAfterProperty: true -ObjCSpaceBeforeProtocolList: true -PenaltyBreakAssignment: 10 -PenaltyBreakBeforeFirstCallParameter: 30 -PenaltyBreakComment: 10 -PenaltyBreakFirstLessLess: 0 -PenaltyBreakOpenParenthesis: 0 -PenaltyBreakString: 10 -PenaltyBreakTemplateDeclaration: 10 -PenaltyExcessCharacter: 100 -PenaltyReturnTypeOnItsOwnLine: 60 -PenaltyIndentedWhitespace: 0 -PointerAlignment: Left -PPIndentWidth: -1 -ReferenceAlignment: Pointer -ReflowComments: false -RemoveBracesLLVM: false -SeparateDefinitionBlocks: Leave -ShortNamespaceLines: 1 -SortIncludes: Never -SortJavaStaticImport: Before -SortUsingDeclarations: false -SpaceAfterCStyleCast: false -SpaceAfterLogicalNot: false -SpaceAfterTemplateKeyword: true -SpaceBeforeAssignmentOperators: true -SpaceBeforeCaseColon: false -SpaceBeforeCpp11BracedList: false -SpaceBeforeCtorInitializerColon: true -SpaceBeforeInheritanceColon: true -SpaceBeforeParens: Never -SpaceBeforeParensOptions: - AfterControlStatements: false - AfterForeachMacros: false - AfterFunctionDefinitionName: false - AfterFunctionDeclarationName: false - AfterIfMacros: false - AfterOverloadedOperator: false - BeforeNonEmptyParentheses: false -SpaceAroundPointerQualifiers: Default -SpaceBeforeRangeBasedForLoopColon: true -SpaceInEmptyBlock: false -SpaceInEmptyParentheses: false -SpacesBeforeTrailingComments: 1 -SpacesInAngles: Never -SpacesInConditionalStatement: false -SpacesInContainerLiterals: false -SpacesInCStyleCastParentheses: false -SpacesInLineCommentPrefix: - Minimum: 1 - Maximum: -1 -SpacesInParentheses: false -SpacesInSquareBrackets: false -SpaceBeforeSquareBrackets: false -BitFieldColonSpacing: Both -Standard: c++03 -StatementAttributeLikeMacros: - - Q_EMIT -StatementMacros: - - Q_UNUSED - - QT_REQUIRE_VERSION -TabWidth: 4 -UseCRLF: false -UseTab: Never -WhitespaceSensitiveMacros: - - STRINGIZE - - PP_STRINGIZE - - BOOST_PP_STRINGIZE - - NS_SWIFT_NAME - - CF_SWIFT_NAME -... - diff --git a/project_template/.editorconfig b/project_template/.editorconfig deleted file mode 100644 index a31ef8e..0000000 --- a/project_template/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -root = true - -[*] -end_of_line = lf -insert_final_newline = true -charset = utf-8 - -[*.{cpp,h,c,py,sh}] -indent_style = space -indent_size = 4 - -[{Makefile,*.mk}] -indent_size = tab diff --git a/project_template/.vscode/c_cpp_properties.json b/project_template/.vscode/c_cpp_properties.json deleted file mode 100644 index 922a909..0000000 --- a/project_template/.vscode/c_cpp_properties.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "configurations": [ - { - "name": "main", - "compilerPath": "@UFBT_TOOLCHAIN_GCC@", - "intelliSenseMode": "gcc-arm", - "compileCommands": "${workspaceFolder}/.vscode/compile_commands.json", - "configurationProvider": "ms-vscode.cpptools", - "cStandard": "gnu17", - "cppStandard": "c++17" - }, - ], - "version": 4 -} \ No newline at end of file diff --git a/project_template/.vscode/extensions.json b/project_template/.vscode/extensions.json deleted file mode 100644 index b53ffc2..0000000 --- a/project_template/.vscode/extensions.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. - // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp - // List of extensions which should be recommended for users of this workspace. - "recommendations": [ - "ms-python.black-formatter", - "ms-vscode.cpptools", - "amiralizadeh9480.cpp-helper", - "marus25.cortex-debug", - "zxh404.vscode-proto3", - "augustocdias.tasks-shell-input" - ], - // List of extensions recommended by VS Code that should not be recommended for users of this workspace. - "unwantedRecommendations": [] -} \ No newline at end of file diff --git a/project_template/.vscode/launch.json b/project_template/.vscode/launch.json deleted file mode 100644 index d9c98dc..0000000 --- a/project_template/.vscode/launch.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "inputs": [ - // { - // "id": "BLACKMAGIC", - // "type": "command", - // "command": "shellCommand.execute", - // "args": { - // "useSingleResult": true, - // "env": { - // "PATH": "${workspaceFolder};${env:PATH}" - // }, - // "command": "./fbt get_blackmagic", - // "description": "Get Blackmagic device", - // } - // }, - ], - "configurations": [ - { - "name": "Attach FW (ST-Link)", - "cwd": "${workspaceFolder}", - "executable": "@UFBT_FIRMWARE_ELF@", - "request": "attach", - "type": "cortex-debug", - "servertype": "openocd", - "device": "stlink", - "svdFile": "@UFBT_DEBUG_DIR@/STM32WB55_CM4.svd", - "rtos": "FreeRTOS", - "configFiles": [ - "interface/stlink.cfg", - "@UFBT_DEBUG_DIR@/stm32wbx.cfg" - ], - "postAttachCommands": [ - "source @UFBT_DEBUG_DIR@/flipperapps.py", - "fap-set-debug-elf-root @UFBT_DEBUG_ELF_DIR@" - ], - // "showDevDebugOutput": "raw", - }, - { - "name": "Attach FW (DAP)", - "cwd": "${workspaceFolder}", - "executable": "@UFBT_FIRMWARE_ELF@", - "request": "attach", - "type": "cortex-debug", - "servertype": "openocd", - "device": "cmsis-dap", - "svdFile": "@UFBT_DEBUG_DIR@/STM32WB55_CM4.svd", - "rtos": "FreeRTOS", - "configFiles": [ - "interface/cmsis-dap.cfg", - "@UFBT_DEBUG_DIR@/stm32wbx.cfg" - ], - "postAttachCommands": [ - "source @UFBT_DEBUG_DIR@/flipperapps.py", - "fap-set-debug-elf-root @UFBT_DEBUG_ELF_DIR@" - ], - // "showDevDebugOutput": "raw", - }, - // { - // "name": "Attach FW (blackmagic)", - // "cwd": "${workspaceFolder}", - // "executable": "@UFBT_FIRMWARE_ELF@", - // "request": "attach", - // "type": "cortex-debug", - // "servertype": "external", - // "gdbTarget": "${input:BLACKMAGIC}", - // "svdFile": "@UFBT_DEBUG_DIR@/STM32WB55_CM4.svd", - // "rtos": "FreeRTOS", - // "postAttachCommands": [ - // "monitor swdp_scan", - // "attach 1", - // "set confirm off", - // "set mem inaccessible-by-default off", - // "source @UFBT_DEBUG_DIR@/flipperapps.py", - // "fap-set-debug-elf-root @UFBT_DEBUG_ELF_DIR@" - // ] - // // "showDevDebugOutput": "raw", - // }, - { - "name": "Attach FW (JLink)", - "cwd": "${workspaceFolder}", - "executable": "@UFBT_FIRMWARE_ELF@", - "request": "attach", - "type": "cortex-debug", - "servertype": "jlink", - "interface": "swd", - "device": "STM32WB55RG", - "svdFile": "@UFBT_DEBUG_DIR@/STM32WB55_CM4.svd", - "rtos": "FreeRTOS", - "postAttachCommands": [ - "source @UFBT_DEBUG_DIR@/flipperapps.py", - "fap-set-debug-elf-root @UFBT_DEBUG_ELF_DIR@" - ] - // "showDevDebugOutput": "raw", - }, - ] -} \ No newline at end of file diff --git a/project_template/.vscode/settings.json b/project_template/.vscode/settings.json deleted file mode 100644 index 33cd3f0..0000000 --- a/project_template/.vscode/settings.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "cortex-debug.enableTelemetry": false, - "cortex-debug.variableUseNaturalFormat": false, - "cortex-debug.showRTOS": true, - "cortex-debug.armToolchainPath": "@UFBT_TOOLCHAIN_ARM_TOOLCHAIN_DIR@", - "cortex-debug.openocdPath": "@UFBT_TOOLCHAIN_OPENOCD@", - "cortex-debug.gdbPath": "@UFBT_TOOLCHAIN_GDB_PY@", - "editor.formatOnSave": true, - "files.associations": { - "*.scons": "python", - "SConscript": "python", - "SConstruct": "python", - "*.fam": "python" - }, - "cortex-debug.registerUseNaturalFormat": false, - "python.analysis.typeCheckingMode": "off", - "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" - } -} \ No newline at end of file diff --git a/project_template/.vscode/tasks.json b/project_template/.vscode/tasks.json deleted file mode 100644 index f0bc07d..0000000 --- a/project_template/.vscode/tasks.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "options": { - "env": { - "PATH": "${workspaceFolder}@UFBT_VSCODE_PATH_SEP@${env:PATH}@UFBT_VSCODE_PATH_SEP@@UFBT_ROOT_DIR@" - } - }, - "tasks": [ - { - "label": "Launch App on Flipper", - "group": "build", - "type": "shell", - "command": "ufbt launch" - }, - { - "label": "Build", - "group": "build", - "type": "shell", - "command": "ufbt" - }, - { - "label": "Flash FW (ST-Link)", - "group": "build", - "type": "shell", - "command": "ufbt FORCE=1 flash" - }, - // { - // "label": "[NOTIMPL] Flash FW (blackmagic)", - // "group": "build", - // "type": "shell", - // "command": "ufbt flash_blackmagic" - // }, - // { - // "label": "[NOTIMPL] Flash FW (JLink)", - // "group": "build", - // "type": "shell", - // "command": "ufbt FORCE=1 jflash" - // }, - { - "label": "Flash FW (USB, with resources)", - "group": "build", - "type": "shell", - "command": "ufbt FORCE=1 flash_usb" - } - ] -} \ No newline at end of file diff --git a/project_template/app_template/${FBT_APPID}.c b/project_template/app_template/${FBT_APPID}.c deleted file mode 100644 index 9b8113c..0000000 --- a/project_template/app_template/${FBT_APPID}.c +++ /dev/null @@ -1,12 +0,0 @@ -#include - -/* generated by fbt from .png files in images folder */ -#include <@FBT_APPID@_icons.h> - -int32_t @FBT_APPID@_app(void* p) { - UNUSED(p); - FURI_LOG_I("TEST", "Hello world"); - FURI_LOG_I("TEST", "I'm @FBT_APPID@!"); - - return 0; -} diff --git a/project_template/app_template/${FBT_APPID}.png b/project_template/app_template/${FBT_APPID}.png deleted file mode 100644 index 59e6c18..0000000 Binary files a/project_template/app_template/${FBT_APPID}.png and /dev/null differ diff --git a/project_template/app_template/application.fam b/project_template/app_template/application.fam deleted file mode 100644 index 27acfc9..0000000 --- a/project_template/app_template/application.fam +++ /dev/null @@ -1,17 +0,0 @@ -# For details & more options, see documentation/AppManifests.md in firmware repo - -App( - appid="@FBT_APPID@", # Must be unique - name="App @FBT_APPID@", # Displayed in UI - apptype=FlipperAppType.EXTERNAL, - entry_point="@FBT_APPID@_app", - stack_size=2 * 1024, - fap_category="Misc", - # Optional values - # fap_version=(0, 1), # (major, minor) - fap_icon="@FBT_APPID@.png", # 10x10 1-bit PNG - # fap_description="A simple app", - # fap_author="J. Doe", - # fap_weburl="https://github.com/user/@FBT_APPID@", - fap_icon_assets="images", # Image assets to compile for this application -) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8c56ea8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools", "setuptools-git-versioning<2"] +build-backend = "setuptools.build_meta" + +[project] +name = "ufbt" +dynamic = ["version"] +authors = [{ name = "Flipper Devices Inc.", email = "pypi@flipperdevices.com" }] +description = "uFBT - micro Flipper Build Tool. Tool for building and developing applications (.fap) for Flipper Zero and its device family." +readme = "README.md" +requires-python = ">=3.8" +keywords = ["ufbt", "flipperzero", "fbt", "stm32", "fap"] +license = { text = "GPL-3.0" } +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: C", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Compilers", + "Topic :: Software Development :: Embedded Systems", + "Development Status :: 4 - Beta", + "Environment :: Console", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", +] + + +[project.urls] +homepage = "https://github.com/flipperdevices/flipperzero-ufbt" +documentation = "https://github.com/flipperdevices/flipperzero-ufbt" +repository = "https://github.com/flipperdevices/flipperzero-ufbt" +issues = "https://github.com/flipperdevices/flipperzero-ufbt/issues" + +[project.scripts] +ufbt = "ufbt:ufbt_cli" +ufbt-bootstrap = "ufbt.bootstrap:bootstrap_cli" + +# https://setuptools-git-versioning.readthedocs.io/en/stable/schemas/file/dev_release_file.html#development-releases-prereleases-from-dev-branch +[tool.setuptools-git-versioning] +enabled = true +version_file = "VERSION.txt" +count_commits_from_version_file = true +dev_template = "{tag}.dev{ccount}" +dirty_template = "{tag}.dev{ccount}" diff --git a/site_scons/commandline.scons b/site_scons/commandline.scons deleted file mode 100644 index 0a61857..0000000 --- a/site_scons/commandline.scons +++ /dev/null @@ -1,98 +0,0 @@ -AddOption( - "--proxy-env", - action="store", - dest="proxy_env", - default="", - help="Comma-separated list of additional environment variables to pass to child SCons processes", -) - -# AddOption( -# "--target", -# action="store", -# dest="sdk_target", -# default="", -# help="Hardware target to use for SDK", -# ) - -AddOption( - "--channel", - action="store", - dest="sdk_channel", - choices=["dev", "rc", "release"], - default="", - help="Release channel to use for SDK", -) - -AddOption( - "--branch", - action="store", - dest="sdk_branch", - help="Custom main repo branch to use for SDK", -) - -AddOption( - "--hw-target", - action="store", - dest="sdk_target", - help="SDK Hardware target", -) - -vars = Variables("ufbt_options.py", ARGUMENTS) - -vars.AddVariables( - BoolVariable( - "VERBOSE", - help="Print full commands", - default=False, - ), - BoolVariable( - "FORCE", - help="Force target action (for supported targets)", - default=False, - ), - # These 2 are inherited from SDK - # BoolVariable( - # "DEBUG", - # help="Enable debug build", - # default=True, - # ), - # BoolVariable( - # "COMPACT", - # help="Optimize for size", - # default=False, - # ), - PathVariable( - "OTHER_ELF", - help="Path to prebuilt ELF file to debug", - validator=PathVariable.PathAccept, - default="", - ), - ( - "OPENOCD_OPTS", - "Options to pass to OpenOCD", - "", - ), - ( - "BLACKMAGIC", - "Blackmagic probe location", - "auto", - ), - ( - "OPENOCD_ADAPTER_SERIAL", - "OpenOCD adapter serial number", - "auto", - ), - ( - "APPID", - "Application id", - "", - ), - PathVariable( - "UFBT_APP_DIR", - help="Application dir to work with", - validator=PathVariable.PathIsDir, - default="", - ), -) - -Return("vars") diff --git a/site_scons/site_init.py b/site_scons/site_init.py deleted file mode 100644 index 557085e..0000000 --- a/site_scons/site_init.py +++ /dev/null @@ -1,36 +0,0 @@ -from SCons.Script import GetBuildFailures -import SCons.Errors - -import atexit -from ansi.color import fg, fx - - -def bf_to_str(bf): - """Convert an element of GetBuildFailures() to a string - in a useful way.""" - - if bf is None: # unknown targets product None in list - return "(unknown tgt)" - elif isinstance(bf, SCons.Errors.StopError): - return fg.yellow(str(bf)) - elif bf.node: - return fg.yellow(str(bf.node)) + ": " + bf.errstr - elif bf.filename: - return fg.yellow(bf.filename) + ": " + bf.errstr - return fg.yellow("unknown failure: ") + bf.errstr - - -def display_build_status(): - """Display the build status. Called by atexit. - Here you could do all kinds of complicated things.""" - bf = GetBuildFailures() - if bf: - # bf is normally a list of build failures; if an element is None, - # it's because of a target that scons doesn't know anything about. - failures_message = "\n".join([bf_to_str(x) for x in bf if x is not None]) - print() - print(fg.brightred(fx.bold("*" * 10 + " FBT ERRORS " + "*" * 10))) - print(failures_message) - - -atexit.register(display_build_status) diff --git a/site_scons/site_tools/ufbt_help.py b/site_scons/site_tools/ufbt_help.py deleted file mode 100644 index adb3d6a..0000000 --- a/site_scons/site_tools/ufbt_help.py +++ /dev/null @@ -1,45 +0,0 @@ -targets_help = """Configuration variables: -""" - -tail_help = """ - -TASKS: - (* - not supported yet) - - vscode_dist: - Configure application in current directory for development in VSCode. - app_template: - Copy application template to current directory. - -Building: - faps: - Build all FAP apps - fap_{APPID}, launch_app APPSRC={APPID}: - Build FAP app with appid={APPID}; upload & start it over USB - -Flashing & debugging: - flash, flash_blackmagic, *jflash: - Flash firmware to target using debug probe - flash_usb, flash_usb_full: - Install firmware using self-update package - debug, debug_other, blackmagic: - Start GDB - -Other: - cli: - Open a Flipper CLI session over USB - *lint, *lint_py: - run linters - *format, *format_py: - run code formatters -""" - - -def generate(env, **kw): - vars = kw["vars"] - basic_help = vars.GenerateHelpText(env) - env.Help(targets_help + basic_help + tail_help) - - -def exists(env): - return True diff --git a/site_scons/site_tools/ufbt_state.py b/site_scons/site_tools/ufbt_state.py deleted file mode 100644 index 88803d2..0000000 --- a/site_scons/site_tools/ufbt_state.py +++ /dev/null @@ -1,108 +0,0 @@ -from SCons.Errors import SConsEnvironmentError - -import json -import os -import sys -import pathlib -from functools import reduce - - -def _load_sdk_data(sdk_root): - split_vars = { - "cc_args", - "cpp_args", - "linker_args", - "linker_libs", - } - subst_vars = split_vars | { - "sdk_symbols", - } - sdk_data = {} - with open(os.path.join(sdk_root, "sdk.opts")) as f: - sdk_json_data = json.load(f) - replacements = { - sdk_json_data["app_ep_subst"]: "${APP_ENTRY}", - sdk_json_data["sdk_path_subst"]: sdk_root.replace("\\", "/"), - sdk_json_data["map_file_subst"]: "${TARGET}", - } - - def do_value_substs(src_value): - if isinstance(src_value, str): - return reduce( - lambda acc, kv: acc.replace(*kv), replacements.items(), src_value - ) - elif isinstance(src_value, list): - return [do_value_substs(v) for v in src_value] - else: - return src_value - - for key, value in sdk_json_data.items(): - if key in split_vars: - value = value.split() - if key in subst_vars: - value = do_value_substs(value) - sdk_data[key] = value - - return sdk_data - - -def generate(env, **kw): - ufbt_work_dir = env.Dir(kw.get("UFBT_WORK_DIR", "#.ufbt")) - sdk_meta_filename = kw.get("SDK_META", "sdk_state.json") - - sdk_state_dir_node = ufbt_work_dir.Dir("current") - - sdk_meta_path = os.path.join(sdk_state_dir_node.abspath, sdk_meta_filename) - if not os.path.exists(sdk_meta_path): - raise SConsEnvironmentError(f"SDK state file {sdk_meta_path} not found") - - with open(sdk_meta_path, "r") as f: - sdk_state = json.load(f) - - if not (sdk_components := sdk_state.get("components", {})): - raise SConsEnvironmentError("SDK state file doesn't contain components data") - - sdk_options_path = os.path.join( - sdk_state_dir_node.abspath, sdk_components.get("sdk", "sdk") - ) - sdk_data = _load_sdk_data(sdk_options_path) - if not sdk_state["meta"]["hw_target"].endswith(sdk_data["hardware"]): - raise SConsEnvironmentError("SDK state file doesn't match hardware target") - - env.SetDefault( - # Paths - SDK_DEFINITION=env.File(sdk_data["sdk_symbols"]), - UFBT_STATE_DIR=sdk_state_dir_node, - FBT_DEBUG_DIR=pathlib.Path( - sdk_state_dir_node.Dir(sdk_components.get("scripts", ".")) - .Dir("debug") - .abspath - ).as_posix(), - FBT_SCRIPT_DIR=sdk_state_dir_node.Dir(sdk_components.get("scripts", ".")).Dir( - "scripts" - ), - LIBPATH=sdk_state_dir_node.Dir(sdk_components.get("lib", "lib")), - FW_ELF=sdk_state_dir_node.File(sdk_components.get("fwelf")), - FW_BIN=sdk_state_dir_node.File(sdk_components.get("fwbin")), - UPDATE_BUNDLE_DIR=sdk_state_dir_node.Dir(sdk_components.get("fwbundle")), - SVD_FILE="${FBT_DEBUG_DIR}/STM32WB55_CM4.svd", - # Build variables - ROOT_DIR=env.Dir("#"), - FIRMWARE_BUILD_CFG="firmware", - TARGET_HW=int(sdk_data["hardware"]), - CFLAGS_APP=sdk_data["cc_args"], - CXXFLAGS_APP=sdk_data["cpp_args"], - LINKFLAGS_APP=sdk_data["linker_args"], - LIBS=sdk_data["linker_libs"], - # ufbt state - UFBT_WORK_DIR=ufbt_work_dir, - UFBT_SDK_DIR=sdk_state_dir_node, - UFBT_SDK_META=sdk_state["meta"], - UFBT_BOOTSTRAP_SCRIPT=env.File("#/bootstrap.py"), - ) - - sys.path.insert(0, env["FBT_SCRIPT_DIR"].abspath) - - -def exists(env): - return True diff --git a/site_scons/update.scons b/site_scons/update.scons deleted file mode 100644 index 50bbb34..0000000 --- a/site_scons/update.scons +++ /dev/null @@ -1,37 +0,0 @@ -from SCons.Errors import SConsEnvironmentError - -Import("core_env") - -update_env = core_env.Clone( - toolpath=[core_env["FBT_SCRIPT_DIR"].Dir("fbt_tools")], - tools=["python3"], -) -print("Updating SDK...") -sdk_meta = update_env["UFBT_SDK_META"] - -update_args = [ - "--ufbt-dir", - f'"{update_env["UFBT_WORK_DIR"]}"', -] - -if branch_name := GetOption("sdk_branch"): - update_args.extend(["--branch", branch_name]) -elif channel_name := GetOption("sdk_channel"): - update_args.extend(["--channel", channel_name]) -elif branch_name := sdk_meta.get("branch", None): - update_args.extend(["--branch", branch_name]) -elif channel_name := sdk_meta.get("channel", None): - update_args.extend(["--channel", channel_name]) -else: - raise SConsEnvironmentError("No branch or channel specified for SDK update") - -if hw_target := GetOption("sdk_target"): - update_args.extend(["--hw-target", hw_target]) -else: - update_args.extend(["--hw-target", sdk_meta["hw_target"]]) - -update_env.Replace(UPDATE_ARGS=update_args) -result = update_env.Execute( - update_env.subst('$PYTHON3 "$UFBT_BOOTSTRAP_SCRIPT" $UPDATE_ARGS'), -) -Exit(result) diff --git a/ufbt b/ufbt deleted file mode 100755 index 23da563..0000000 --- a/ufbt +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh - -# shellcheck disable=SC2086 source=/dev/null -# unofficial strict mode -set -eu; - -# private variables -SCRIPT_PATH="$(cd "$(dirname "$0")" && pwd -P)"; -SCONS_DEFAULT_FLAGS="-Q --warn=target-not-built -C $SCRIPT_PATH"; -SCONS_EP="python3 -m SCons" - -# Check if .ufbt dir exists -if [ ! -d "$SCRIPT_PATH/.ufbt/current" ]; then - echo "Bootstrapping ufbt..."; - python3 "$SCRIPT_PATH/bootstrap.py" "--ufbt-dir=$SCRIPT_PATH/.ufbt" --channel dev; -fi - -FBT_TOOLCHAIN_PATH="${FBT_TOOLCHAIN_PATH:-$SCRIPT_PATH/.ufbt}"; - -UFBT_APP_DIR=`pwd`; - -. "$SCRIPT_PATH/.ufbt/current/scripts/toolchain/fbtenv.sh"; - -$SCONS_EP $SCONS_DEFAULT_FLAGS "UFBT_APP_DIR=$UFBT_APP_DIR" "$@" diff --git a/ufbt.cmd b/ufbt.cmd deleted file mode 100755 index 8ad6d16..0000000 --- a/ufbt.cmd +++ /dev/null @@ -1,16 +0,0 @@ -@echo off - -if not exist "%~dp0\.ufbt\current" ( - echo Bootstrapping ufbt... - python "%~dp0\bootstrap.py" "--ufbt-dir=%~dp0\.ufbt" --channel dev -) - -set "FBT_TOOLCHAIN_ROOT=%~dp0\.ufbt\toolchain\x86_64-windows" - -call "%~dp0\.ufbt\current\scripts\toolchain\fbtenv.cmd" env - -set SCONS_EP=python -m SCons -set UFBT_ROOT_DIR="%~dp0." - -set "SCONS_DEFAULT_FLAGS=-Q --warn=target-not-built -C %UFBT_ROOT_DIR%" -%SCONS_EP% %SCONS_DEFAULT_FLAGS% UFBT_APP_DIR="%cd%" %* diff --git a/ufbt/__init__.py b/ufbt/__init__.py new file mode 100644 index 0000000..43e8aa5 --- /dev/null +++ b/ufbt/__init__.py @@ -0,0 +1,52 @@ +import os +import pathlib +import platform +import sys + +from .bootstrap import bootstrap_cli, bootstrap_subcommands + + +def ufbt_cli(): + if not os.environ.get("UFBT_STATE_DIR"): + os.environ["UFBT_STATE_DIR"] = os.path.expanduser("~/.ufbt") + if not os.environ.get("FBT_TOOLCHAIN_PATH"): + os.environ["FBT_TOOLCHAIN_PATH"] = os.environ["UFBT_STATE_DIR"] + + ufbt_state_dir = pathlib.Path(os.environ["UFBT_STATE_DIR"]) + + # if any of bootstrap subcommands are in the arguments - call it instead + # kept for compatibility with old scripts, better use `ufbt-bootstrap` directly + if any(map(sys.argv.__contains__, bootstrap_subcommands)): + return bootstrap_cli() + + if not os.path.exists(ufbt_state_dir): + bootstrap_cli() + + if not (ufbt_state_dir / "current" / "scripts" / "ufbt").exists(): + print("SDK is missing scripts distribution!") + print("You might be trying to use an SDK in an outdated format.") + print("Run `ufbt update -h` for more information on how to update.") + return 1 + + UFBT_APP_DIR = os.getcwd() + + if platform.system() == "Windows": + commandline = ( + 'call "%UFBT_STATE_DIR%/current/scripts/toolchain/fbtenv.cmd" env & ' + f'python -m SCons -Q --warn=target-not-built -C "%UFBT_STATE_DIR%/current/scripts/ufbt" "UFBT_APP_DIR={UFBT_APP_DIR}" ' + + " ".join(sys.argv[1:]) + ) + + else: + commandline = ( + '. "$UFBT_STATE_DIR/current/scripts/toolchain/fbtenv.sh" && ' + f'python3 -m SCons -Q --warn=target-not-built -C "$UFBT_STATE_DIR/current/scripts/ufbt" "UFBT_APP_DIR={UFBT_APP_DIR}" ' + + " ".join(sys.argv[1:]) + ) + + # print(commandline) + return os.system(commandline) + + +if __name__ == "__main__": + sys.exit(ufbt_cli() or 0) diff --git a/ufbt/__main__.py b/ufbt/__main__.py new file mode 100644 index 0000000..7efe653 --- /dev/null +++ b/ufbt/__main__.py @@ -0,0 +1,6 @@ +from . import ufbt_cli + +if __name__ == "__main__": + import sys + + sys.exit(ufbt_cli() or 0) diff --git a/ufbt/bootstrap.py b/ufbt/bootstrap.py new file mode 100644 index 0000000..b36d524 --- /dev/null +++ b/ufbt/bootstrap.py @@ -0,0 +1,729 @@ +### +# Bootstrap script for uFBT. Deploys SDK and metadata. +### + +import argparse +import enum +import json +import logging +import os +import re +import shutil +import sys +from dataclasses import dataclass, field +from html.parser import HTMLParser +from pathlib import Path, PurePosixPath +from typing import ClassVar, Dict, Optional +from urllib.parse import unquote, urlparse +from urllib.request import Request, urlopen +from zipfile import ZipFile + +logging.basicConfig( + format="%(asctime)s.%(msecs)03d [%(levelname).1s] %(message)s", + level=logging.INFO, + datefmt="%H:%M:%S", +) +log = logging.getLogger(__name__) + +############################################################################## + + +class FileType(enum.Enum): + SDK_ZIP = "sdk_zip" + LIB_ZIP = "lib_zip" + CORE2_FIRMWARE_TGZ = "core2_firmware_tgz" + RESOURCES_TGZ = "resources_tgz" + SCRIPTS_TGZ = "scripts_tgz" + UPDATE_TGZ = "update_tgz" + FIRMWARE_ELF = "firmware_elf" + FULL_BIN = "full_bin" + FULL_DFU = "full_dfu" + FULL_JSON = "full_json" + UPDATER_BIN = "updater_bin" + UPDATER_DFU = "updater_dfu" + UPDATER_ELF = "updater_elf" + UPDATER_JSON = "updater_json" + + +class BaseSdkLoader: + """ + Base class for SDK loaders. + """ + + VERSION_UNKNOWN = "unknown" + ALWAYS_UPDATE_VERSIONS = [VERSION_UNKNOWN, "local"] + USER_AGENT = "uFBT SDKLoader/0.2" + _SSL_CONTEXT = None + + def __init__(self, download_dir: str): + self._download_dir = download_dir + + def _open_url(self, url: str): + request = Request(url, headers={"User-Agent": self.USER_AGENT}) + return urlopen(request, context=self._SSL_CONTEXT) + + def _fetch_file(self, url: str) -> str: + log.debug(f"Fetching {url}") + file_name = PurePosixPath(unquote(urlparse(url).path)).parts[-1] + file_path = os.path.join(self._download_dir, file_name) + + os.makedirs(self._download_dir, exist_ok=True) + + with self._open_url(url) as response, open(file_path, "wb") as out_file: + data = response.read() + out_file.write(data) + + return file_path + + # Returns local FS path. Downloads file if necessary + def get_sdk_component(self, target: str) -> str: + raise NotImplementedError() + + # Constructs metadata dict from loader-specific data + def get_metadata(self) -> Dict[str, str]: + raise NotImplementedError() + + # Reconstruction of loader-specific data from metadata dict + @classmethod + def metadata_to_init_kwargs(cls, metadata: dict) -> Dict[str, str]: + raise NotImplementedError() + + # Conversion of argparse.Namespace to metadata dict + @classmethod + def args_namespace_to_metadata( + cls, namespace: argparse.Namespace + ) -> Dict[str, str]: + raise NotImplementedError() + + @classmethod + def add_args_to_mode_group(cls, mode_group): + raise NotImplementedError() + + +class BranchSdkLoader(BaseSdkLoader): + """ + Loads SDK from a branch on update server. + Uses HTML parsing of index page to find all files in the branch. + """ + + LOADER_MODE_KEY = "branch" + UPDATE_SERVER_BRANCH_ROOT = "https://update.flipperzero.one/builds/firmware" + + class LinkExtractor(HTMLParser): + FILE_NAME_RE = re.compile(r"flipper-z-(\w+)-(\w+)-(.+)\.(\w+)") + + def reset(self) -> None: + super().reset() + self.files = {} + self.version = None + + def handle_starttag(self, tag, attrs): + if tag == "a" and (href := dict(attrs).get("href", None)): + # .map files have special naming and we don't need them + if ".map" in href: + return + if match := self.FILE_NAME_RE.match(href): + target, file_type, version, ext = match.groups() + file_type_str = f"{file_type}_{ext}".upper() + if file_type := FileType._member_map_.get(file_type_str, None): + self.files[(file_type, target)] = href + if not self.version: + self.version = version + elif not version.startswith(self.version): + raise RuntimeError( + f"Found multiple versions: {self.version} and {version}" + ) + + def __init__(self, download_dir: str, branch: str, branch_root_url: str = None): + super().__init__(download_dir) + self._branch = branch + self._branch_root = branch_root_url or self.UPDATE_SERVER_BRANCH_ROOT + self._branch_url = f"{self._branch_root}/{branch}/" + self._branch_files = {} + self._version = None + self._fetch_branch() + + def _fetch_branch(self) -> None: + # Fetch html index page with links to files + log.info(f"Fetching branch index {self._branch_url}") + with self._open_url(self._branch_url) as response: + html = response.read().decode("utf-8") + extractor = BranchSdkLoader.LinkExtractor() + extractor.feed(html) + self._branch_files = extractor.files + self._version = extractor.version + log.info(f"Found version {self._version}") + + def get_sdk_component(self, target: str) -> str: + if not (file_name := self._branch_files.get((FileType.SDK_ZIP, target), None)): + raise ValueError(f"SDK bundle not found for {target}") + + return self._fetch_file(self._branch_url + file_name) + + def get_metadata(self) -> Dict[str, str]: + return { + "mode": self.LOADER_MODE_KEY, + "branch": self._branch, + "version": self._version, + "branch_root": self._branch_root, + } + + @classmethod + def metadata_to_init_kwargs(cls, metadata: dict) -> Dict[str, str]: + return { + "branch": metadata["branch"], + "branch_root_url": metadata.get( + "branch_root", BranchSdkLoader.UPDATE_SERVER_BRANCH_ROOT + ), + } + + @classmethod + def args_namespace_to_metadata( + cls, namespace: argparse.Namespace + ) -> Dict[str, str]: + return { + "branch": namespace.branch, + "branch_root": namespace.index_url, + } + + @classmethod + def add_args_to_mode_group(cls, mode_group): + mode_group.add_argument( + "--branch", + "-b", + type=str, + help="Branch to load SDK from", + ) + + +class UpdateChannelSdkLoader(BaseSdkLoader): + """ + Loads SDK from a release channel on update server. + Uses JSON index to find all files in the channel. + Supports official update server and unofficial servers following the same format. + """ + + LOADER_MODE_KEY = "channel" + OFFICIAL_INDEX_URL = "https://update.flipperzero.one/firmware/directory.json" + + class UpdateChannel(enum.Enum): + DEV = "development" + RC = "release-candidate" + RELEASE = "release" + + def __init__( + self, download_dir: str, channel: UpdateChannel, index_html_url: str = None + ): + super().__init__(download_dir) + self.channel = channel + self.index_html_url = index_html_url or self.OFFICIAL_INDEX_URL + self.version_info = self._fetch_version(self.channel) + + def _fetch_version(self, channel: UpdateChannel) -> dict: + log.info(f"Fetching version info for {channel} from {self.index_html_url}") + try: + data = json.loads( + self._open_url(self.index_html_url).read().decode("utf-8") + ) + except json.decoder.JSONDecodeError as e: + raise ValueError(f"Invalid JSON: {e}") + + if not (channels := data.get("channels", [])): + raise ValueError(f"Invalid channel: {channel}") + + channel_data = next((c for c in channels if c["id"] == channel.value), None) + if not channel_data: + raise ValueError(f"Invalid channel: {channel}") + + if not (versions := channel_data.get("versions", [])): + raise ValueError(f"Empty channel: {channel}") + + log.info(f"Using version: {versions[0]['version']}") + log.debug(f"Changelog: {versions[0].get('changelog', 'None')}") + return versions[0] + + @staticmethod + def _get_file_info(version_data: dict, file_type: FileType, file_target: str): + + if not (files := version_data.get("files", [])): + raise ValueError(f"Empty files list") + + if not ( + file_info := next( + ( + f + for f in files + if f["type"] == file_type.value and f["target"] == file_target + ), + None, + ) + ): + raise ValueError(f"Invalid file type: {file_type}") + + return file_info + + def get_sdk_component(self, target: str) -> str: + file_info = self._get_file_info(self.version_info, FileType.SDK_ZIP, target) + if not (file_url := file_info.get("url", None)): + raise ValueError(f"Invalid file url") + + return self._fetch_file(file_url) + + def get_metadata(self) -> Dict[str, str]: + return { + "mode": self.LOADER_MODE_KEY, + "channel": self.channel.name.lower(), + "index_html": self.index_html_url, + "version": self.version_info["version"], + } + + @classmethod + def metadata_to_init_kwargs(cls, metadata: dict) -> Dict[str, str]: + return { + "channel": UpdateChannelSdkLoader.UpdateChannel[ + metadata["channel"].upper() + ], + "index_html_url": metadata.get("index_html", None), + } + + @classmethod + def args_namespace_to_metadata( + cls, namespace: argparse.Namespace + ) -> Dict[str, str]: + return { + "channel": namespace.channel, + "index_html": namespace.index_url, + } + + @classmethod + def add_args_to_mode_group(cls, mode_group): + mode_group.add_argument( + "--channel", + "-c", + type=str, + help="Channel to load SDK from", + choices=[c.name.lower() for c in cls.UpdateChannel], + ) + + +class UrlSdkLoader(BaseSdkLoader): + """ + Loads SDK from a static URL. Does not extract version info. + """ + + LOADER_MODE_KEY = "url" + + def __init__(self, download_dir: str, url: str): + super().__init__(download_dir) + self.url = url + + def get_sdk_component(self, target: str) -> str: + log.info(f"Fetching SDK from {self.url}") + return self._fetch_file(self.url) + + def get_metadata(self) -> Dict[str, str]: + return { + "mode": self.LOADER_MODE_KEY, + "url": self.url, + "version": self.VERSION_UNKNOWN, + } + + @classmethod + def metadata_to_init_kwargs(cls, metadata: dict) -> Dict[str, str]: + return {"url": metadata["url"]} + + @classmethod + def args_namespace_to_metadata( + cls, namespace: argparse.Namespace + ) -> Dict[str, str]: + return {"url": namespace.url} + + @classmethod + def add_args_to_mode_group(cls, mode_group): + mode_group.add_argument( + "--url", + "-u", + type=str, + help="Direct URL to load SDK from", + ) + + +class LocalSdkLoader(BaseSdkLoader): + """ + Loads SDK from a file in filesystem. Does not extract version info. + """ + + LOADER_MODE_KEY = "local" + + def __init__(self, download_dir: str, file_path: str): + super().__init__(download_dir) + self.file_path = file_path + + def get_sdk_component(self, target: str) -> str: + log.info(f"Loading SDK from {self.file_path}") + return self.file_path + + def get_metadata(self) -> Dict[str, str]: + return { + "mode": self.LOADER_MODE_KEY, + "file_path": self.file_path, + "version": self.VERSION_UNKNOWN, + } + + @classmethod + def metadata_to_init_kwargs(cls, metadata: dict) -> Dict[str, str]: + return {"file_path": metadata["file_path"]} + + @classmethod + def args_namespace_to_metadata(cls, args: argparse.Namespace) -> Dict[str, str]: + return {"file_path": args.local} + + @classmethod + def add_args_to_mode_group(cls, mode_group): + mode_group.add_argument( + f"--local", + f"-l", + type=str, + help="Path to local SDK zip file", + ) + + +all_boostrap_loader_cls = ( + BranchSdkLoader, + UpdateChannelSdkLoader, + UrlSdkLoader, + LocalSdkLoader, +) + + +############################################################################## + + +@dataclass +class SdkDeployTask: + """ + Wrapper for SDK deploy task parameters. + """ + + hw_target: str = None + force: bool = False + mode: str = None + all_params: Dict[str, str] = field(default_factory=dict) + + DEFAULT_HW_TARGET: ClassVar[str] = "f7" + + def update_from(self, other: "SdkDeployTask") -> None: + log.debug(f"deploy task update from {other=}") + if other.hw_target: + self.hw_target = other.hw_target + + if other.mode: + self.mode = other.mode + + self.force = other.force + for key, value in other.all_params.items(): + if value: + self.all_params[key] = value + log.debug(f"deploy task updated: {self=}") + + @staticmethod + def default() -> "SdkDeployTask": + task = SdkDeployTask() + task.hw_target = SdkDeployTask.DEFAULT_HW_TARGET + task.mode = "channel" + task.all_params["channel"] = UpdateChannelSdkLoader.UpdateChannel.RELEASE.value + return task + + @staticmethod + def from_args(args: argparse.Namespace) -> "SdkDeployTask": + task = SdkDeployTask() + task.hw_target = args.hw_target or SdkDeployTask.DEFAULT_HW_TARGET + task.force = args.force + for loader_cls in all_boostrap_loader_cls: + task.all_params.update(loader_cls.args_namespace_to_metadata(args)) + if getattr(args, loader_cls.LOADER_MODE_KEY): + task.mode = loader_cls.LOADER_MODE_KEY + break + log.debug(f"deploy task from args: {task=}") + return task + + @staticmethod + def from_dict(data: Dict[str, str]) -> "SdkDeployTask": + task = SdkDeployTask() + task.hw_target = data.get("hw_target") + task.force = False + task.mode = data.get("mode") + task.all_params = data + return task + + +class SdkLoaderFactory: + @staticmethod + def create_for_task(task: SdkDeployTask, download_dir: str) -> BaseSdkLoader: + log.debug(f"SdkLoaderFactory::create_for_task {task=}") + loader_cls = None + for loader_cls in all_boostrap_loader_cls: + if loader_cls.LOADER_MODE_KEY == task.mode: + break + if loader_cls is None: + raise ValueError(f"Invalid mode: {task.mode}") + + ctor_kwargs = loader_cls.metadata_to_init_kwargs(task.all_params) + log.debug(f"SdkLoaderFactory::create_for_task {loader_cls=}, {ctor_kwargs=}") + return loader_cls(download_dir, **ctor_kwargs) + + +class UfbtSdkDeployer: + UFBT_STATE_FILE_NAME = "ufbt_state.json" + + def __init__(self, ufbt_state_dir: str): + self.ufbt_state_dir = Path(ufbt_state_dir) + self.download_dir = self.ufbt_state_dir / "download" + self.current_sdk_dir = self.ufbt_state_dir / "current" + self.state_file = self.current_sdk_dir / self.UFBT_STATE_FILE_NAME + + def get_previous_task(self) -> Optional[SdkDeployTask]: + if not os.path.exists(self.state_file): + return None + with open(self.state_file, "r") as f: + ufbt_state = json.load(f) + log.debug(f"get_previous_task() loaded state: {ufbt_state=}") + return SdkDeployTask.from_dict(ufbt_state) + + def deploy(self, task: SdkDeployTask) -> bool: + log.info(f"Deploying SDK for {task.hw_target}") + sdk_loader = SdkLoaderFactory.create_for_task(task, self.download_dir) + + sdk_target_dir = self.current_sdk_dir.absolute() + log.info(f"uFBT SDK dir: {sdk_target_dir}") + if not task.force and os.path.exists(sdk_target_dir): + # Read existing state + with open(self.state_file, "r") as f: + ufbt_state = json.load(f) + # Check if we need to update + if ufbt_state.get("version") in sdk_loader.ALWAYS_UPDATE_VERSIONS: + log.info("Cannot determine SDK version, updating") + elif ( + ufbt_state.get("version") == sdk_loader.get_metadata().get("version") + and ufbt_state.get("hw_target") == task.hw_target + ): + log.info("SDK is up-to-date") + return True + + try: + sdk_component_path = sdk_loader.get_sdk_component(task.hw_target) + except Exception as e: + log.error(f"Failed to fetch SDK for {task.hw_target}: {e}") + return False + + shutil.rmtree(sdk_target_dir, ignore_errors=True) + + ufbt_state = { + "hw_target": task.hw_target, + **sdk_loader.get_metadata(), + } + + log.info(f"Deploying SDK") + + with ZipFile(sdk_component_path, "r") as zip_file: + zip_file.extractall(sdk_target_dir) + + with open(self.state_file, "w") as f: + json.dump(ufbt_state, f, indent=4) + log.info("SDK deployed.") + return True + + +############################################################################### + + +class CliSubcommand: + def __init__(self, name: str, help: str): + self.name = name + self.help = help + + def add_to_parser(self, parser: argparse.ArgumentParser): + subparser = parser.add_parser(self.name, help=self.help) + subparser.set_defaults(func=self._func) + self._add_arguments(subparser) + + def _func(args) -> int: + raise NotImplementedError + + def _add_arguments(self, parser: argparse.ArgumentParser) -> None: + raise NotImplementedError + + +class UpdateSubcommand(CliSubcommand): + COMMAND = "update" + + def __init__(self): + super().__init__(self.COMMAND, "Update uFBT SDK") + + def _add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--hw-target", + "-t", + help="Hardware target", + ) + parser.add_argument( + "--index-url", + help="URL to use for SDK discovery", + ) + mode_group = parser.add_mutually_exclusive_group(required=False) + for loader_cls in all_boostrap_loader_cls: + loader_cls.add_args_to_mode_group(mode_group) + + def _func(self, args) -> int: + sdk_deployer = UfbtSdkDeployer(args.ufbt_home) + current_task = SdkDeployTask.from_args(args) + task_to_deploy = None + + if previous_task := sdk_deployer.get_previous_task(): + previous_task.update_from(current_task) + task_to_deploy = previous_task + else: + if current_task.mode: + task_to_deploy = current_task + else: + log.error("No previous SDK state was found, fetching latest release") + task_to_deploy = SdkDeployTask.default() + + if not sdk_deployer.deploy(task_to_deploy): + return 1 + return 0 + + +class CleanSubcommand(CliSubcommand): + COMMAND = "clean" + + def __init__(self): + super().__init__(self.COMMAND, "Clean uFBT SDK state") + + def _add_arguments(self, parser: argparse.ArgumentParser): + parser.add_argument( + "--downloads", + help="Clean downloads", + action="store_true", + default=False, + ) + parser.add_argument( + "--purge", + help="Purge whole ufbt state", + action="store_true", + default=False, + ) + + def _func(self, args) -> int: + sdk_deployer = UfbtSdkDeployer(args.ufbt_home) + if args.purge: + log.info(f"Cleaning complete ufbt state in {sdk_deployer.ufbt_state_dir}") + shutil.rmtree(sdk_deployer.ufbt_state_dir, ignore_errors=True) + log.info("Done") + return + + if args.downloads: + log.info(f"Cleaning download dir {sdk_deployer.download_dir}") + shutil.rmtree(sdk_deployer.download_dir, ignore_errors=True) + else: + log.info(f"Cleaning SDK state in {sdk_deployer.current_sdk_dir}") + shutil.rmtree(sdk_deployer.current_sdk_dir, ignore_errors=True) + log.info("Done") + return 0 + + +class StatusSubcommand(CliSubcommand): + COMMAND = "status" + + def __init__(self): + super().__init__(self.COMMAND, "Show uFBT SDK status") + + def _add_arguments(self, parser: argparse.ArgumentParser) -> None: + pass + + def _func(self, args) -> int: + sdk_deployer = UfbtSdkDeployer(args.ufbt_home) + log.info(f"State dir {sdk_deployer.ufbt_state_dir}") + log.info(f"Download dir {sdk_deployer.download_dir}") + log.info(f"SDK dir {sdk_deployer.current_sdk_dir}") + if previous_task := sdk_deployer.get_previous_task(): + log.info(f"Target {previous_task.hw_target}") + log.info(f"Mode {previous_task.mode}") + log.info( + f"Version {previous_task.all_params.get('version', BaseSdkLoader.VERSION_UNKNOWN)}" + ) + log.info(f"Details {previous_task.all_params}") + return 0 + else: + log.error("SDK is not deployed") + return 1 + + +bootstrap_subcommand_classes = (UpdateSubcommand, CleanSubcommand, StatusSubcommand) + +bootstrap_subcommands = ( + subcommand_cls.COMMAND for subcommand_cls in bootstrap_subcommand_classes +) + + +def bootstrap_cli() -> Optional[int]: + root_parser = argparse.ArgumentParser() + root_parser.add_argument( + "--no-check-certificate", + help="Disable SSL certificate verification", + action="store_true", + default=False, + ) + root_parser.add_argument( + "--ufbt-home", + "-d", + help="uFBT state directory", + default=os.environ.get("UFBT_HOME", os.path.expanduser("~/.ufbt")), + ) + root_parser.add_argument( + "--force", + "-f", + help="Force operation", + action="store_true", + default=False, + ) + root_parser.add_argument( + "--verbose", + help="Enable extra logging", + action="store_true", + default=False, + ) + + parsers = root_parser.add_subparsers() + for subcommand_cls in bootstrap_subcommand_classes: + subcommand_cls().add_to_parser(parsers) + + args = root_parser.parse_args() + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + if args.no_check_certificate: + # Temporary fix for SSL negotiation failure on Mac + import ssl + + _ssl_context = ssl.create_default_context() + _ssl_context.check_hostname = False + _ssl_context.verify_mode = ssl.CERT_NONE + BaseSdkLoader.SSL_CONTEXT = _ssl_context + + if "func" not in args: + root_parser.print_help() + return 1 + + try: + return args.func(args) + + except Exception as e: + log.error(f"Failed to run operation: {e}. See --verbose for details") + if args.verbose: + raise + return 2 + + +if __name__ == "__main__": + sys.exit(bootstrap_cli() or 0)