feat: Add support for esptool v5 and keep v4 for now

This commit is contained in:
Peter Dragun
2025-08-15 22:43:34 +02:00
parent 23517c9317
commit eba6b814ba
7 changed files with 240 additions and 175 deletions

View File

@@ -8,10 +8,9 @@ import shutil
import subprocess
import sys
from typing import Any
from typing import List
from unittest import TestCase
from unittest import main
from unittest import mock
from unittest import TestCase
import jsonschema
@@ -37,10 +36,13 @@ class TestWithoutExtensions(TestCase):
@classmethod
def setUpClass(cls):
# Disable the component manager and extra extensions for these tests
cls.env_patcher = mock.patch.dict(os.environ, {
'IDF_COMPONENT_MANAGER': '0',
'IDF_EXTRA_ACTIONS_PATH': '',
})
cls.env_patcher = mock.patch.dict(
os.environ,
{
'IDF_COMPONENT_MANAGER': '0',
'IDF_EXTRA_ACTIONS_PATH': '',
},
)
cls.env_patcher.start()
super().setUpClass()
@@ -51,8 +53,9 @@ class TestExtensions(TestWithoutExtensions):
try:
os.symlink(extension_path, link_path)
os.environ['IDF_EXTRA_ACTIONS_PATH'] = os.path.join(current_dir, 'extra_path')
output = subprocess.check_output([sys.executable, idf_py_path, '--help'],
env=os.environ).decode('utf-8', 'ignore')
output = subprocess.check_output([sys.executable, idf_py_path, '--help'], env=os.environ).decode(
'utf-8', 'ignore'
)
self.assertIn('--test-extension-option', output)
self.assertIn('test_subcommand', output)
@@ -67,7 +70,8 @@ class TestExtensions(TestWithoutExtensions):
os.environ['IDF_EXTRA_ACTIONS_PATH'] = ';'.join([os.path.join(current_dir, 'extra_path')])
output = subprocess.check_output(
[sys.executable, idf_py_path, '--some-extension-option=awesome', 'test_subcommand', 'extra_subcommand'],
env=os.environ).decode('utf-8', 'ignore')
env=os.environ,
).decode('utf-8', 'ignore')
self.assertIn('!!! From some global callback: awesome', output)
self.assertIn('!!! From some subcommand', output)
self.assertIn('!!! From test global callback: test', output)
@@ -79,8 +83,9 @@ class TestExtensions(TestWithoutExtensions):
try:
os.symlink(extension_path, link_path)
os.environ['IDF_EXTRA_ACTIONS_PATH'] = ';'.join([os.path.join(current_dir, 'extra_path')])
output = subprocess.check_output([sys.executable, idf_py_path, '--help'],
env=os.environ).decode('utf-8', 'ignore')
output = subprocess.check_output([sys.executable, idf_py_path, '--help'], env=os.environ).decode(
'utf-8', 'ignore'
)
self.assertIn('test_subcommand', output)
self.assertNotIn('hidden_one', output)
@@ -127,7 +132,8 @@ class TestDependencyManagement(TestWithoutExtensions):
sys.stderr = sys.__stderr__
self.assertIn(
'WARNING: Commands "all", "clean" are found in the list of commands more than once.',
capturedOutput.getvalue())
capturedOutput.getvalue(),
)
sys.stderr = capturedOutput
idf.init_cli()(
@@ -136,7 +142,8 @@ class TestDependencyManagement(TestWithoutExtensions):
)
sys.stderr = sys.__stderr__
self.assertIn(
'WARNING: Command "clean" is found in the list of commands more than once.', capturedOutput.getvalue())
'WARNING: Command "clean" is found in the list of commands more than once.', capturedOutput.getvalue()
)
class TestVerboseFlag(TestWithoutExtensions):
@@ -145,10 +152,13 @@ class TestVerboseFlag(TestWithoutExtensions):
[
sys.executable,
idf_py_path,
'-C%s' % current_dir,
'-C',
current_dir,
'-v',
'test-verbose',
], env=os.environ).decode('utf-8', 'ignore')
],
env=os.environ,
).decode('utf-8', 'ignore')
self.assertIn('Verbose mode on', output)
@@ -157,9 +167,12 @@ class TestVerboseFlag(TestWithoutExtensions):
[
sys.executable,
idf_py_path,
'-C%s' % current_dir,
'-C',
current_dir,
'test-verbose',
], env=os.environ).decode('utf-8', 'ignore')
],
env=os.environ,
).decode('utf-8', 'ignore')
self.assertIn('Output from test-verbose', output)
self.assertNotIn('Verbose mode on', output)
@@ -188,27 +201,31 @@ class TestDeprecations(TestWithoutExtensions):
def test_exit_with_error_for_subcommand(self):
try:
subprocess.check_output(
[sys.executable, idf_py_path, '-C%s' % current_dir, 'test-2'], env=os.environ, stderr=subprocess.STDOUT)
[sys.executable, idf_py_path, '-C', current_dir, 'test-2'], env=os.environ, stderr=subprocess.STDOUT
)
except subprocess.CalledProcessError as e:
self.assertIn('Error: Command "test-2" is deprecated and was removed.', e.output.decode('utf-8', 'ignore'))
def test_exit_with_error_for_option(self):
try:
subprocess.check_output(
[sys.executable, idf_py_path, '-C%s' % current_dir, '--test-5=asdf'],
[sys.executable, idf_py_path, '-C', current_dir, '--test-5=asdf'],
env=os.environ,
stderr=subprocess.STDOUT)
stderr=subprocess.STDOUT,
)
except subprocess.CalledProcessError as e:
self.assertIn(
'Error: Option "test_5" is deprecated since v2.0 and was removed in v3.0.',
e.output.decode('utf-8', 'ignore'))
e.output.decode('utf-8', 'ignore'),
)
def test_deprecation_messages(self):
output = subprocess.check_output(
[
sys.executable,
idf_py_path,
'-C%s' % current_dir,
'-C',
current_dir,
'--test-0=a',
'--test-1=b',
'--test-2=c',
@@ -220,23 +237,28 @@ class TestDeprecations(TestWithoutExtensions):
'test-1',
],
env=os.environ,
stderr=subprocess.STDOUT).decode('utf-8', 'ignore')
stderr=subprocess.STDOUT,
).decode('utf-8', 'ignore')
self.assertIn('Warning: Option "test_sub_1" is deprecated and will be removed in future versions.', output)
self.assertIn(
'Warning: Command "test-1" is deprecated and will be removed in future versions. '
'Please use alternative command.', output)
'Please use alternative command.',
output,
)
self.assertIn('Warning: Option "test_1" is deprecated and will be removed in future versions.', output)
self.assertIn(
'Warning: Option "test_2" is deprecated and will be removed in future versions. '
'Please update your parameters.', output)
'Please update your parameters.',
output,
)
self.assertIn('Warning: Option "test_3" is deprecated and will be removed in future versions.', output)
self.assertNotIn('"test-0" is deprecated', output)
self.assertNotIn('"test_0" is deprecated', output)
class TestHelpOutput(TestWithoutExtensions):
def action_test_idf_py(self, commands: List[str], schema: Any) -> None:
def action_test_idf_py(self, commands: list[str], schema: Any) -> None:
env = dict(**os.environ)
python = shutil.which('python', path=env['PATH'])
if python is None:
@@ -244,20 +266,17 @@ class TestHelpOutput(TestWithoutExtensions):
idf_path = env.get('IDF_PATH')
if idf_path is None:
raise ValueError('Empty IDF_PATH')
idf_py_cmd = [
python,
os.path.join(idf_path, 'tools', 'idf.py')
]
idf_py_cmd = [python, os.path.join(idf_path, 'tools', 'idf.py')]
commands = idf_py_cmd + commands
output_file = 'idf_py_help_output.json'
with open(output_file, 'w') as outfile:
subprocess.run(commands, env=env, stdout=outfile)
with open(output_file, 'r') as outfile:
with open(output_file) as outfile:
help_obj = json.load(outfile)
self.assertIsNone(jsonschema.validate(help_obj, schema))
def test_output(self):
with open(os.path.join(current_dir, 'idf_py_help_schema.json'), 'r') as schema_file:
with open(os.path.join(current_dir, 'idf_py_help_schema.json')) as schema_file:
schema_json = json.load(schema_file)
self.action_test_idf_py(['help', '--json'], schema_json)
self.action_test_idf_py(['help', '--json', '--add-options'], schema_json)
@@ -270,7 +289,8 @@ class TestFileArgumentExpansion(TestCase):
output = subprocess.check_output(
[sys.executable, idf_py_path, '--version', '@file_args_expansion_inputs/args_a'],
env=os.environ,
stderr=subprocess.STDOUT).decode('utf-8', 'ignore')
stderr=subprocess.STDOUT,
).decode('utf-8', 'ignore')
self.assertIn('Running: idf.py --version DAAA DBBB', output)
except subprocess.CalledProcessError as e:
self.fail(f'Process should have exited normally, but it exited with a return code of {e.returncode}')
@@ -279,9 +299,16 @@ class TestFileArgumentExpansion(TestCase):
"""Test multiple @filename arguments"""
try:
output = subprocess.check_output(
[sys.executable, idf_py_path, '--version', '@file_args_expansion_inputs/args_a', '@file_args_expansion_inputs/args_b'],
[
sys.executable,
idf_py_path,
'--version',
'@file_args_expansion_inputs/args_a',
'@file_args_expansion_inputs/args_b',
],
env=os.environ,
stderr=subprocess.STDOUT).decode('utf-8', 'ignore')
stderr=subprocess.STDOUT,
).decode('utf-8', 'ignore')
self.assertIn('Running: idf.py --version DAAA DBBB DCCC DDDD', output)
except subprocess.CalledProcessError as e:
self.fail(f'Process should have exited normally, but it exited with a return code of {e.returncode}')
@@ -292,7 +319,8 @@ class TestFileArgumentExpansion(TestCase):
output = subprocess.check_output(
[sys.executable, idf_py_path, '--version', '@file_args_expansion_inputs/args_recursive'],
env=os.environ,
stderr=subprocess.STDOUT).decode('utf-8', 'ignore')
stderr=subprocess.STDOUT,
).decode('utf-8', 'ignore')
self.assertIn('Running: idf.py --version DAAA DBBB DEEE DFFF', output)
except subprocess.CalledProcessError as e:
self.fail(f'Process should have exited normally, but it exited with a return code of {e.returncode}')
@@ -303,7 +331,8 @@ class TestFileArgumentExpansion(TestCase):
subprocess.check_output(
[sys.executable, idf_py_path, '--version', '@file_args_expansion_inputs/args_circular_a'],
env=os.environ,
stderr=subprocess.STDOUT).decode('utf-8', 'ignore')
stderr=subprocess.STDOUT,
).decode('utf-8', 'ignore')
self.assertIn('Circular dependency in file argument expansion', cm.exception.output.decode('utf-8', 'ignore'))
def test_missing_file(self):
@@ -312,8 +341,11 @@ class TestFileArgumentExpansion(TestCase):
subprocess.check_output(
[sys.executable, idf_py_path, '--version', '@args_non_existent'],
env=os.environ,
stderr=subprocess.STDOUT).decode('utf-8', 'ignore')
self.assertIn('(expansion of @args_non_existent) could not be opened', cm.exception.output.decode('utf-8', 'ignore'))
stderr=subprocess.STDOUT,
).decode('utf-8', 'ignore')
self.assertIn(
'(expansion of @args_non_existent) could not be opened', cm.exception.output.decode('utf-8', 'ignore')
)
class TestWrapperCommands(TestCase):
@@ -323,12 +355,11 @@ class TestWrapperCommands(TestCase):
os.chdir(cls.sample_project_dir)
super().setUpClass()
def call_command(self, command: List[str]) -> str:
def call_command(self, command: list[str]) -> str:
try:
output = subprocess.check_output(
command,
env=os.environ,
stderr=subprocess.STDOUT).decode('utf-8', 'ignore')
output = subprocess.check_output(command, env=os.environ, stderr=subprocess.STDOUT).decode(
'utf-8', 'ignore'
)
return output
except subprocess.CalledProcessError as e:
self.fail(f'Process should have exited normally, but it exited with a return code of {e.returncode}')
@@ -343,7 +374,8 @@ class TestWrapperCommands(TestCase):
class TestEFuseCommands(TestWrapperCommands):
"""
Test if wrapper commands for espefuse.py are working as expected.
The goal is NOT to test the functionality of espefuse.py, but to test if the wrapper commands are working as expected.
The goal is NOT to test the functionality of espefuse.py, but to test if the wrapper commands
are working as expected.
"""
def test_efuse_summary(self):
@@ -351,17 +383,17 @@ class TestEFuseCommands(TestWrapperCommands):
output = self.call_command(summary_command)
self.assertIn('EFUSE_NAME (Block) Description = [Meaningful Value] [Readable/Writeable] (Hex Value)', output)
output = self.call_command(summary_command + ['--format','summary'])
output = self.call_command(summary_command + ['--format', 'summary'])
self.assertIn('00:00:00:00:00:00', output)
self.assertIn('MAC address', output)
output = self.call_command(summary_command + ['--format','value-only', 'WR_DIS'])
output = self.call_command(summary_command + ['--format', 'value-only', 'WR_DIS'])
self.assertIn('0', output)
def test_efuse_burn(self):
burn_command = [sys.executable, idf_py_path, 'efuse-burn', '--virt', '--do-not-confirm']
output = self.call_command(burn_command + ['WR_DIS', '1'])
self.assertIn('\'WR_DIS\' (Efuse write disable mask) 0x0000 -> 0x0001', output)
self.assertIn("'WR_DIS' (Efuse write disable mask) 0x0000 -> 0x0001", output)
self.assertIn('Successful', output)
output = self.call_command(burn_command + ['WR_DIS', '1', 'RD_DIS', '1'])
@@ -371,9 +403,14 @@ class TestEFuseCommands(TestWrapperCommands):
def test_efuse_burn_key(self):
key_name = 'efuse_test_key.bin'
subprocess.run([sys.executable, idf_py_path, 'secure-generate-flash-encryption-key', os.path.join(current_dir, key_name)], stdout=subprocess.DEVNULL)
subprocess.run(
[sys.executable, idf_py_path, 'secure-generate-flash-encryption-key', os.path.join(current_dir, key_name)],
stdout=subprocess.DEVNULL,
)
burn_key_command = [sys.executable, idf_py_path, 'efuse-burn-key', '--virt', '--do-not-confirm']
output = self.call_command(burn_key_command + ['--show-sensitive-info', 'secure_boot_v1', os.path.join(current_dir, key_name)])
output = self.call_command(
burn_key_command + ['--show-sensitive-info', 'secure_boot_v1', os.path.join(current_dir, key_name)]
)
self.assertIn('Burn keys to blocks:', output)
self.assertIn('Successful', output)
@@ -401,8 +438,10 @@ class TestEFuseCommands(TestWrapperCommands):
class TestSecureCommands(TestWrapperCommands):
"""
Test if wrapper commands for espsecure.py are working as expected.
The goal is NOT to test the functionality of espsecure.py, but to test if the wrapper commands are working as expected.
The goal is NOT to test the functionality of espsecure.py, but to test if the wrapper commands are
working as expected.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
@@ -412,106 +451,125 @@ class TestSecureCommands(TestWrapperCommands):
cls.nvs_partition_key = 'nvs_partition_key.bin'
def secure_generate_flash_encryption_key(self):
generate_key_command = [sys.executable, idf_py_path, 'secure-generate-flash-encryption-key', self.flash_encryption_key]
generate_key_command = [
sys.executable,
idf_py_path,
'secure-generate-flash-encryption-key',
self.flash_encryption_key,
]
output = self.call_command(generate_key_command)
self.assertIn(f'Writing 256 random bits to key file {self.flash_encryption_key}', output)
self.assertRegex(output, f'Writing 256 random bits to key file "?{self.flash_encryption_key}"?')
def secure_encrypt_flash_data(self):
self.secure_generate_flash_encryption_key()
encrypt_command = [sys.executable,
idf_py_path,
'secure-encrypt-flash-data',
'--aes-xts',
'--keyfile',
f'../{self.flash_encryption_key}',
'--address',
'0x1000',
'--output',
'bootloader-enc.bin',
'bootloader/bootloader.bin']
encrypt_command = [
sys.executable,
idf_py_path,
'secure-encrypt-flash-data',
'--aes-xts',
'--keyfile',
f'../{self.flash_encryption_key}',
'--address',
'0x1000',
'--output',
'bootloader-enc.bin',
'bootloader/bootloader.bin',
]
output = self.call_command(encrypt_command)
self.assertIn('Using 256-bit key', output)
self.assertIn('Done', output)
def test_secure_decrypt_flash_data(self):
self.secure_encrypt_flash_data()
decrypt_command = [sys.executable,
idf_py_path,
'secure-decrypt-flash-data',
'--aes-xts',
'--keyfile',
f'../{self.flash_encryption_key}',
'--address',
'0x1000',
'--output',
'bootloader-dec.bin',
'bootloader-enc.bin']
decrypt_command = [
sys.executable,
idf_py_path,
'secure-decrypt-flash-data',
'--aes-xts',
'--keyfile',
f'../{self.flash_encryption_key}',
'--address',
'0x1000',
'--output',
'bootloader-dec.bin',
'bootloader-enc.bin',
]
output = self.call_command(decrypt_command)
self.assertIn('Using 256-bit key', output)
self.assertIn('Done', output)
def secure_sign_data(self):
self.secure_generate_signing_key()
sign_command = [sys.executable,
idf_py_path,
'secure-sign-data',
'--version',
'2',
'--keyfile',
f'../{self.signing_key}',
'--output',
'bootloader-signed.bin',
'bootloader/bootloader.bin']
sign_command = [
sys.executable,
idf_py_path,
'secure-sign-data',
'--version',
'2',
'--keyfile',
f'../{self.signing_key}',
'--output',
'bootloader-signed.bin',
'bootloader/bootloader.bin',
]
output = self.call_command(sign_command)
self.assertIn('Signed', output)
def secure_verify_signature(self):
self.secure_sign_data()
sign_command = [sys.executable,
idf_py_path,
'secure-verify-signature',
'--version',
'2',
'--keyfile',
f'../{self.signing_key}',
'bootloader-signed.bin']
sign_command = [
sys.executable,
idf_py_path,
'secure-verify-signature',
'--version',
'2',
'--keyfile',
f'../{self.signing_key}',
'bootloader-signed.bin',
]
output = self.call_command(sign_command)
self.assertIn('verification successful', output)
def secure_generate_signing_key(self):
generate_key_command = [sys.executable,
idf_py_path,
'secure-generate-signing-key',
'--version',
'2',
'--scheme',
'rsa3072',
self.signing_key]
generate_key_command = [
sys.executable,
idf_py_path,
'secure-generate-signing-key',
'--version',
'2',
'--scheme',
'rsa3072',
self.signing_key,
]
output = self.call_command(generate_key_command)
self.assertIn(f'RSA 3072 private key in PEM format written to {self.signing_key}', output)
self.assertRegex(output, f'RSA 3072 private key in PEM format written to "?{self.signing_key}"?')
def test_secure_generate_key_digest(self):
self.secure_generate_signing_key()
digest_command = [sys.executable,
idf_py_path,
'secure-generate-key-digest',
'--keyfile',
f'{self.signing_key}',
'--output',
'key_digest.bin']
digest_command = [
sys.executable,
idf_py_path,
'secure-generate-key-digest',
'--keyfile',
f'{self.signing_key}',
'--output',
'key_digest.bin',
]
output = self.call_command(digest_command)
self.assertIn(f'Writing the public key digest of {self.signing_key} to key_digest.bin', output)
self.assertRegex(output, f'Writing the public key digest of "?{self.signing_key}"? to "?key_digest.bin"?.')
def test_secure_generate_nvs_partition_key(self):
generate_key_command = [sys.executable,
idf_py_path,
'secure-generate-nvs-partition-key',
'--keyfile',
f'{self.nvs_partition_key}',
'--encryption-scheme',
'HMAC',
'--hmac-keyfile',
'nvs_partition_key.bin']
generate_key_command = [
sys.executable,
idf_py_path,
'secure-generate-nvs-partition-key',
'--keyfile',
f'{self.nvs_partition_key}',
'--encryption-scheme',
'HMAC',
'--hmac-keyfile',
'nvs_partition_key.bin',
]
output = self.call_command(generate_key_command)
self.assertIn('Created encryption keys:', output)
@@ -519,14 +577,15 @@ class TestSecureCommands(TestWrapperCommands):
class TestMergeBinCommands(TestWrapperCommands):
"""
Test if merge-bin command is invoked as expected.
This test is not testing the functionality of esptool.py merge_bin command, but the invocation of the command from idf.py.
This test is not testing the functionality of esptool.py merge_bin command, but the invocation of
the command from idf.py.
"""
def test_merge_bin(self):
merge_bin_command = [sys.executable, idf_py_path, 'merge-bin']
merged_binary_name = 'test-merge-binary.bin'
output = self.call_command(merge_bin_command + ['--output', merged_binary_name])
self.assertIn(f'file {merged_binary_name}, ready to flash to offset 0x0', output)
self.assertRegex(output, f"file '?{merged_binary_name}'?, ready to flash to offset 0x0")
self.assertIn(f'Merged binary {merged_binary_name} will be created in the build directory...', output)