diff --git a/.gitignore b/.gitignore index 4c17bb63a8..9f5635050f 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,9 @@ examples/thermostat/ameba/build # Downloaded zap without a pigweed root (via zap_download.py) .zap +# Downloaded zap via west commands +.zap-install + # When building esp-idf application, if any component is fetched using idf-component-manager then they are stored in # managed_component directory. Along with that dependencies.lock file is generated. # https://github.com/espressif/idf-component-manager#using-with-a-project diff --git a/scripts/west/west-commands.yml b/scripts/west/west-commands.yml new file mode 100644 index 0000000000..b17c5efd56 --- /dev/null +++ b/scripts/west/west-commands.yml @@ -0,0 +1,12 @@ +# Keep the help strings in sync with the values in the .py files! +west-commands: + - file: scripts/west/zap_generate.py + commands: + - name: zap-generate + class: ZapGenerate + help: Generate ZAP code + - file: scripts/west/zap_gui.py + commands: + - name: zap-gui + class: ZapGui + help: Run Matter ZCL Advanced Platform (ZAP) GUI diff --git a/scripts/west/zap_common.py b/scripts/west/zap_common.py new file mode 100644 index 0000000000..1af23cae59 --- /dev/null +++ b/scripts/west/zap_common.py @@ -0,0 +1,186 @@ +# Copyright (c) 2024 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause + +import argparse +import os +import platform +import re +import shutil +import stat +import subprocess +import tempfile +import wget + +from pathlib import Path +from typing import Tuple +from zipfile import ZipFile + +from west import log + +MATTER_PATH = Path(__file__).parents[2] + + +def find_zap(root: Path = Path.cwd(), max_depth: int = 1): + """ + Find *.zap file in the given directory or its subdirectories. + """ + subdirs = [] + for name in root.iterdir(): + if name.is_file() and (name.suffix.lower() == '.zap'): + return root / name + if name.is_dir() and (max_depth > 0): + subdirs.append(name) + for subdir in subdirs: + if zap := find_zap(root / subdir, max_depth - 1): + return zap + return None + + +def existing_file_path(arg: str) -> Path: + """ + Helper function to validate file path argument. + """ + p = Path(arg) + if p.is_file(): + return p + raise argparse.ArgumentTypeError(f'invalid file path: \'{arg}\'') + + +class ZapInstaller: + INSTALL_DIR = Path('.zap-install') + ZAP_URL_PATTERN = 'https://github.com/project-chip/zap/releases/download/v%04d.%02d.%02d-nightly/%s.zip' + + def __init__(self, matter_path: Path): + self.matter_path = matter_path + self.install_path = matter_path / ZapInstaller.INSTALL_DIR + + def unzip_darwin(zip: Path, out: Path): + subprocess.check_call(['unzip', zip, '-d', out]) + + def unzip(zip: Path, out: Path): + f = ZipFile(zip) + f.extractall(out) + f.close() + + current_os = platform.system() + if current_os == 'Linux': + self.package = 'zap-linux-x64' + self.zap_exe = 'zap' + self.zap_cli_exe = 'zap-cli' + self.unzip = unzip + elif current_os == 'Windows': + self.package = 'zap-win-x64' + self.zap_exe = 'zap.exe' + self.zap_cli_exe = 'zap-cli.exe' + self.unzip = unzip + elif current_os == 'Darwin': + self.package = 'zap-mac-x64' + self.zap_exe = 'zap.app/Contents/MacOS/zap' + self.zap_cli_exe = 'zap-cli' + self.unzip = unzip_darwin + else: + raise RuntimeError(f"Unsupported platform: {current_os}") + + def get_install_path(self) -> Path: + """ + Returns ZAP package installation directory. + """ + return self.install_path + + def get_zap_path(self) -> Path: + """ + Returns path to ZAP GUI. + """ + return self.install_path / self.zap_exe + + def get_zap_cli_path(self) -> Path: + """ + Returns path to ZAP CLI. + """ + return self.install_path / self.zap_cli_exe + + def get_recommended_version(self) -> Tuple[int, int, int]: + """ + Returns ZAP package recommended version as a tuple of integers. + + Parses zap_execution.py script from Matter SDK to determine the minimum + required ZAP package version. + """ + RE_MIN_ZAP_VERSION = r'MIN_ZAP_VERSION\s*=\s*\'(\d+)\.(\d+)\.(\d+)' + zap_execution_path = self.matter_path / 'scripts/tools/zap/zap_execution.py' + + with open(zap_execution_path, 'r') as f: + if match := re.search(RE_MIN_ZAP_VERSION, f.read()): + return tuple(int(group) for group in match.groups()) + raise RuntimeError(f'Failed to find MIN_ZAP_VERSION in {zap_execution_path}') + + def get_current_version(self) -> Tuple[int, int, int]: + """ + Returns ZAP package current version as a tuple of integers. + + Parses the output of `zap --version` to determine the current ZAP + package version. If the ZAP package has not been installed yet, + the method returns None. + """ + try: + output = subprocess.check_output( + [self.get_zap_path(), '--version']).decode('ascii').strip() + except Exception: + return None + + RE_VERSION = r'Version:\s*(\d+)\.(\d+)\.(\d+)' + if match := re.search(RE_VERSION, output): + return tuple(int(group) for group in match.groups()) + + raise RuntimeError("Failed to find version in ZAP output") + + def install_zap(self, version: Tuple[int, int, int]) -> None: + """ + Downloads and unpacks selected ZAP package version. + """ + with tempfile.TemporaryDirectory() as temp_dir: + url = ZapInstaller.ZAP_URL_PATTERN % (*version, self.package) + log.inf(f'Downloading {url}...') + + try: + wget.download(url, out=temp_dir) + except Exception as e: + raise RuntimeError(f'Failed to download ZAP package from {url}: {e}') + + shutil.rmtree(self.install_path, ignore_errors=True) + + log.inf('') # Fix console after displaying wget progress bar + log.inf(f'Unzipping ZAP package to {self.install_path}...') + + try: + self.unzip(Path(temp_dir) / f'{self.package}.zip', self.install_path) + except Exception as e: + raise RuntimeError(f'Failed to unzip ZAP package: {e}') + + ZapInstaller.set_exec_permission(self.get_zap_path()) + ZapInstaller.set_exec_permission(self.get_zap_cli_path()) + + def update_zap_if_needed(self) -> None: + """ + Installs ZAP package if not up to date. + + Installs or overrides the previous ZAP package installation if the + current version does not match the recommended version. + """ + recommended_version = self.get_recommended_version() + current_version = self.get_current_version() + + if current_version == recommended_version: + log.inf('ZAP is up to date: {0}.{1}.{2}'.format(*recommended_version)) + return + + if current_version: + log.inf('Found ZAP version: {0}.{1}.{2}'.format(*current_version)) + + log.inf('Installing ZAP version: {0}.{1}.{2}'.format(*recommended_version)) + self.install_zap(recommended_version) + + @staticmethod + def set_exec_permission(path: Path) -> None: + os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC) diff --git a/scripts/west/zap_generate.py b/scripts/west/zap_generate.py new file mode 100644 index 0000000000..a20d5d90b3 --- /dev/null +++ b/scripts/west/zap_generate.py @@ -0,0 +1,70 @@ +# Copyright (c) 2024 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause + +import argparse +import os +import sys + +from pathlib import Path +from textwrap import dedent + +from west import log +from west.commands import CommandError, WestCommand + +from zap_common import existing_file_path, find_zap, ZapInstaller, MATTER_PATH + + +class ZapGenerate(WestCommand): + + def __init__(self): + super().__init__( + 'zap-generate', # gets stored as self.name + 'Generate Matter data model files with ZAP', # self.help + # self.description: + dedent(''' + Generate Matter data model files with the use of ZAP Tool + based on the .zap template file defined for your application.''')) + + def do_add_parser(self, parser_adder): + parser = parser_adder.add_parser(self.name, + help=self.help, + formatter_class=argparse.RawDescriptionHelpFormatter, + description=self.description) + parser.add_argument('-z', '--zap-file', type=existing_file_path, + help='Path to data model configuration file (*.zap)') + parser.add_argument('-o', '--output', type=Path, + help='Path where to store the generated files') + return parser + + def do_run(self, args, unknown_args): + if args.zap_file: + zap_file_path = args.zap_file.absolute() + else: + zap_file_path = find_zap() + + if not zap_file_path: + raise CommandError("No valid .zap file provided") + + if args.output: + output_path = args.output + else: + output_path = zap_file_path.parent / "zap-generated" + + app_templates_path = MATTER_PATH / "src/app/zap-templates/app-templates.json" + zap_generate_path = MATTER_PATH / "scripts/tools/zap/generate.py" + + zap_installer = ZapInstaller(MATTER_PATH) + zap_installer.update_zap_if_needed() + + # make sure that the generate.py script uses the proper zap_cli binary (handled by west) + os.environ["ZAP_INSTALL_PATH"] = str(zap_installer.get_zap_cli_path().parent.absolute()) + + cmd = [sys.executable, zap_generate_path] + cmd += [zap_file_path] + cmd += ["-t", app_templates_path] + cmd += ["-o", output_path] + + self.check_call([str(x) for x in cmd]) + + log.inf(f"Done. Files generated in {output_path}") diff --git a/scripts/west/zap_gui.py b/scripts/west/zap_gui.py new file mode 100644 index 0000000000..d321e0af39 --- /dev/null +++ b/scripts/west/zap_gui.py @@ -0,0 +1,61 @@ +# Copyright (c) 2024 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause + +import argparse + +from pathlib import Path +from textwrap import dedent + +from zap_common import existing_file_path, find_zap, ZapInstaller, MATTER_PATH +from west.commands import WestCommand + + +class ZapGui(WestCommand): + + def __init__(self): + super().__init__( + 'zap-gui', + 'Run Matter ZCL Advanced Platform (ZAP) GUI', + dedent(''' + Run Matter ZCL Advanced Platform (ZAP) GUI. + + The ZAP GUI in a node.js tool for configuring the data model + of a Matter application, which defines clusters, commands, + attributes and events enabled for the given application.''')) + + def do_add_parser(self, parser_adder): + parser = parser_adder.add_parser(self.name, + help=self.help, + formatter_class=argparse.RawDescriptionHelpFormatter, + description=self.description) + parser.add_argument('-z', '--zap-file', type=existing_file_path, + help='Path to data model configuration file (*.zap)') + parser.add_argument('-j', '--zcl-json', type=existing_file_path, + help='Path to data model definition file (zcl.json)') + return parser + + def do_run(self, args, unknown_args): + if args.zap_file: + zap_file_path = args.zap_file + else: + zap_file_path = find_zap() + + if args.zcl_json: + zcl_json_path = args.zcl_json.absolute() + else: + zcl_json_path = MATTER_PATH / 'src/app/zap-templates/zcl/zcl.json' + + app_templates_path = MATTER_PATH / 'src/app/zap-templates/app-templates.json' + + zap_installer = ZapInstaller(Path(MATTER_PATH)) + zap_installer.update_zap_if_needed() + zap_cache_path = zap_installer.get_install_path() / ".zap" + + cmd = [zap_installer.get_zap_path()] + cmd += [zap_file_path] if zap_file_path else [] + cmd += ["--zcl", zcl_json_path] + cmd += ["--gen", app_templates_path] + cmd += ["--stateDirectory", zap_cache_path] + + self.check_call([str(x) for x in cmd])