Skip to content

Commit

Permalink
[nrf noup] Add custom west commands to ease ZAP usage
Browse files Browse the repository at this point in the history
Add "zap-gui" west command that runs ZAP GUI to edit
the requested ZAP file.

Add "zap-generate" west command that generates the cluster
code out of the requested ZAP file.

Both commands verify if a correct version of ZAP package
is installed in .zap-install directory in the Matter SDK
module, and update the installed version accordingly.

The commands also try to find the ZAP file in the current
directory if the ZAP file path is not explicitly provided.
  • Loading branch information
Damian-Nordic committed Apr 11, 2024
1 parent 83caae1 commit aaf6893
Show file tree
Hide file tree
Showing 5 changed files with 332 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions scripts/west/west-commands.yml
Original file line number Diff line number Diff line change
@@ -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
186 changes: 186 additions & 0 deletions scripts/west/zap_common.py
Original file line number Diff line number Diff line change
@@ -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)
70 changes: 70 additions & 0 deletions scripts/west/zap_generate.py
Original file line number Diff line number Diff line change
@@ -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}")
61 changes: 61 additions & 0 deletions scripts/west/zap_gui.py
Original file line number Diff line number Diff line change
@@ -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])

0 comments on commit aaf6893

Please sign in to comment.