esp-test-utils 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/CHANGELOG.md +8 -0
  2. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/PKG-INFO +1 -1
  3. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/docs/conf.py +3 -0
  4. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esp_test_utils.egg-info/PKG-INFO +1 -1
  5. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esp_test_utils.egg-info/SOURCES.txt +1 -0
  6. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/common/decorators.py +42 -9
  7. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/scripts/downbin.py +2 -1
  8. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/tools/download_bin.py +62 -11
  9. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/utility/parse_bin_path.py +51 -9
  10. esp_test_utils-0.3.2/tests/tools/test_download_bin.py +81 -0
  11. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/utility/test_parse_bin_path.py +31 -0
  12. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/.github/.gitkeep +0 -0
  13. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/.github/workflows/pypi-publish.yml +0 -0
  14. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/.gitignore +0 -0
  15. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/.gitlab-ci.yml +0 -0
  16. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/.pre-commit-config.yaml +0 -0
  17. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/CONTRIBUTING.md +0 -0
  18. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/LICENSE +0 -0
  19. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/README.md +0 -0
  20. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/docs/Makefile +0 -0
  21. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/docs/index.rst +0 -0
  22. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/docs/make.bat +0 -0
  23. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esp_test_utils.egg-info/dependency_links.txt +0 -0
  24. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esp_test_utils.egg-info/entry_points.txt +0 -0
  25. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esp_test_utils.egg-info/requires.txt +0 -0
  26. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esp_test_utils.egg-info/top_level.txt +0 -0
  27. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/__init__.py +0 -0
  28. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/__main__.py +0 -0
  29. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/__init__.py +0 -0
  30. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/dut/__init__.py +0 -0
  31. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/dut/create_dut.py +0 -0
  32. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/dut/dut_base.py +0 -0
  33. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/dut/esp_dut.py +0 -0
  34. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/dut/esp_mixin.py +0 -0
  35. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/dut/esp_port.py +0 -0
  36. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/dut/mac_mixin.py +0 -0
  37. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/dut/wrapper.py +0 -0
  38. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/port/__init__.py +0 -0
  39. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/port/base_port.py +0 -0
  40. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/port/serial_port.py +0 -0
  41. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/adapter/port/shell_port.py +0 -0
  42. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/all.py +0 -0
  43. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/common/__init__.py +0 -0
  44. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/common/compat_typing.py +0 -0
  45. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/common/data_monitor.py +0 -0
  46. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/common/encoding.py +0 -0
  47. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/common/generator.py +0 -0
  48. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/common/shell.py +0 -0
  49. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/common/timestamp.py +0 -0
  50. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/config/__init__.py +0 -0
  51. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/config/default_config.py +0 -0
  52. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/config/env_config.py +0 -0
  53. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/db/__init__.py +0 -0
  54. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/db/runners.py +0 -0
  55. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/devices/__init__.py +0 -0
  56. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/devices/attenuator.py +0 -0
  57. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/devices/esp_serial.py +0 -0
  58. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/devices/serial_dut.py +0 -0
  59. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/devices/serial_tools.py +0 -0
  60. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/devices/switch.py +0 -0
  61. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/env/__init__.py +0 -0
  62. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/env/base_env.py +0 -0
  63. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/env/wifi_env.py +0 -0
  64. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/esp_console/__init__.py +0 -0
  65. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/esp_console/wifi_cmd.py +0 -0
  66. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/interface/__init__.py +0 -0
  67. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/interface/dut.py +0 -0
  68. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/interface/port.py +0 -0
  69. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/iperf_utility/__init__.py +0 -0
  70. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/iperf_utility/iperf_results.py +0 -0
  71. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/iperf_utility/iperf_test.py +0 -0
  72. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/iperf_utility/iperf_test.test.py +0 -0
  73. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/iperf_utility/line_chart.py +0 -0
  74. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/logger/__init__.py +0 -0
  75. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/logger/logger.py +0 -0
  76. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/network/__init__.py +0 -0
  77. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/network/mac.py +0 -0
  78. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/network/netif.py +0 -0
  79. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/network/nic.py +0 -0
  80. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/scripts/list_ports.py +0 -0
  81. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/scripts/monitor.py +0 -0
  82. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/scripts/set_att.py +0 -0
  83. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/tools/copy_bin.py +0 -0
  84. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/tools/http_download.py +0 -0
  85. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/tools/pip_check.py +0 -0
  86. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/tools/uart_monitor.py +0 -0
  87. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/esptest/utility/gen_esp32part.py +0 -0
  88. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/example/jap_test.py +0 -0
  89. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/example/restart_test.py +0 -0
  90. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/pyproject.toml +0 -0
  91. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/setup.cfg +0 -0
  92. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/__init__.py +0 -0
  93. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/adapter/test_Dut.py +0 -0
  94. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/adapter/test_shell_port.py +0 -0
  95. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/basic/test_decorators.py +0 -0
  96. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/basic/test_network.py +0 -0
  97. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/conftest.py +0 -0
  98. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/db/test_db_runners.py +0 -0
  99. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/devices/test_switch.py +0 -0
  100. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/esp_console/_files/wifi_cmd_connected_1.log +0 -0
  101. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/esp_console/_files/wifi_cmd_connected_2.log +0 -0
  102. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/esp_console/conftest.py +0 -0
  103. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/esp_console/test_WifiCmd.py +0 -0
  104. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/iperf_utility/_files/dut_iperf_rx1.log +0 -0
  105. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/iperf_utility/_files/dut_iperf_rx2.log +0 -0
  106. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/iperf_utility/_files/pc_iperf_rx.log +0 -0
  107. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/iperf_utility/_files/pc_iperf_rx2.log +0 -0
  108. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/iperf_utility/test_chart.py +0 -0
  109. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/iperf_utility/test_iperf_results.py +0 -0
  110. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/iperf_utility/test_iperf_util.py +0 -0
  111. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/test_EnvConfig.py +0 -0
  112. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/test_common.py +0 -0
  113. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/test_import.py +0 -0
  114. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/tools/test_download_file.py +0 -0
  115. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/tools/test_pip_check.py +0 -0
  116. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/utility/_files/test-bin.zip +0 -0
  117. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tests/utility/_files/test-get-baud/ESP32AT-V4.1.1.0/sdkconfig +0 -0
  118. {esp_test_utils-0.3.1 → esp_test_utils-0.3.2}/tools/ci/check_dev_version.py +0 -0
@@ -1,3 +1,11 @@
1
+ ## v0.3.2 (2026-03-19)
2
+
3
+
4
+ - feat: add downbin with configs
5
+ - feat: add secure boot match check
6
+ - fix: parse partition for read-only dir
7
+ - change: add debug logs for downbin
8
+
1
9
  ## v0.3.1 (2026-01-16)
2
10
 
3
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: esp-test-utils
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: ESP Test Utils
5
5
  Author-email: Chen Yudong <chenyudong@espressif.com>
6
6
  License: Apache License
@@ -20,6 +20,9 @@ extensions = [
20
20
  'sphinx.ext.napoleon',
21
21
  ]
22
22
 
23
+ # Use type hints from signature in parameter descriptions (avoids duplicating complex types in docstrings)
24
+ autodoc_typehints = 'description'
25
+
23
26
  templates_path = ['_templates']
24
27
  exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
25
28
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: esp-test-utils
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: ESP Test Utils
5
5
  Author-email: Chen Yudong <chenyudong@espressif.com>
6
6
  License: Apache License
@@ -107,6 +107,7 @@ tests/iperf_utility/_files/dut_iperf_rx1.log
107
107
  tests/iperf_utility/_files/dut_iperf_rx2.log
108
108
  tests/iperf_utility/_files/pc_iperf_rx.log
109
109
  tests/iperf_utility/_files/pc_iperf_rx2.log
110
+ tests/tools/test_download_bin.py
110
111
  tests/tools/test_download_file.py
111
112
  tests/tools/test_pip_check.py
112
113
  tests/utility/test_parse_bin_path.py
@@ -16,6 +16,18 @@ GenericFunc = t.TypeVar('GenericFunc', bound=t.Callable[..., t.Any])
16
16
 
17
17
 
18
18
  def enhance_import_error_message(message: str) -> t.Callable[[GenericFunc], GenericFunc]:
19
+ """Decorator that enriches ImportError with function name and custom message.
20
+
21
+ When the decorated function raises an ImportError, the exception message
22
+ is appended with `` from {func.__name__}: {message}`` to aid fixing.
23
+
24
+ Args:
25
+ message (str): Extra hint to append to the ImportError message.
26
+
27
+ Returns:
28
+ t.Callable[[GenericFunc], GenericFunc]: A decorator for the target function.
29
+ """
30
+
19
31
  def decorator(func: GenericFunc) -> GenericFunc:
20
32
  @wraps(func)
21
33
  def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any:
@@ -42,17 +54,26 @@ def retry(
42
54
  ) -> t.Callable[[GenericFunc], GenericFunc]:
43
55
  """Retry decorator
44
56
 
45
- For parameter "on_result", it can be a list or a callable.
57
+ The decorated function is called at most ``max_retry`` times. A retry happens when:
58
+ - The return value fails the ``on_result`` check (if configured), or
59
+ - An exception matching ``on_exception`` is raised (if configured).
46
60
 
61
+ **on_result** controls retry based on return value. It can be:
62
+ - **list**: Retry when the return value is **not** in the list; stop and return when it is in the list.
63
+ - **callable**: Retry when the callable returns True (result unacceptable); stop and return when it returns False.
64
+ Default is a callable that always returns False, so no retry based on result.
65
+
66
+ **on_exception** limits which exceptions trigger a retry. Only exceptions whose type is in this tuple
67
+ are caught and cause a retry; others are re-raised. Default uses an internal sentinel so no retry on exception.
47
68
 
48
69
  Args:
49
- max_retry (int, optional): Max retry count. Defaults to 3.
50
- on_result (Union[List[t.Any], Callable[[Any], bool]], optional): Retry if the result if not expected.
51
- on_exception (Tuple[Type[Exception], ...], optional): Retry if exception, do not handle exception by default.
52
- delay (float, optional): Delay before next retry. Defaults to 0.
70
+ max_retry: Maximum number of total calls. Defaults to 3.
71
+ on_result: Retry based on return value, see description above. Default: no retry on result.
72
+ on_exception: Retry when one of these exceptions is raised. Default: no exception handled.
73
+ delay: Delay before next retry. Defaults to 0.
53
74
 
54
75
  Returns:
55
- t.Callable[[GenericFunc], GenericFunc]: decorator
76
+ t.Callable[[GenericFunc], GenericFunc]: A decorator for the target function.
56
77
  """
57
78
 
58
79
  def decorator(func: GenericFunc) -> GenericFunc:
@@ -98,12 +119,13 @@ def deprecated(reason: str = '') -> t.Callable[[GenericFunc], GenericFunc]:
98
119
 
99
120
 
100
121
  def suppress_stdout() -> t.Callable[[GenericFunc], GenericFunc]:
101
- """Disable all stdout and stderr"""
122
+ """Redirect stdout and stderr to discard output during the decorated function's execution."""
102
123
 
103
124
  def decorator(func: GenericFunc) -> GenericFunc:
104
125
  @wraps(func)
105
126
  def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any:
106
- with contextlib.redirect_stdout(io.StringIO()):
127
+ devnull = io.StringIO()
128
+ with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
107
129
  return func(*args, **kwargs)
108
130
 
109
131
  return t.cast(GenericFunc, wrapper)
@@ -115,7 +137,18 @@ def timeit(
115
137
  print_func: t.Callable[[str], None] = logger.critical,
116
138
  format_str: str = 'Func {func_name} time used: {time_used:.2f} s',
117
139
  ) -> t.Callable[[GenericFunc], GenericFunc]:
118
- """Show time used when method is called"""
140
+ """Show time used after method is called.
141
+
142
+ After the function returns, ``print_func`` is called with the formatted string
143
+ (supports ``{func_name}`` and ``{time_used}`` placeholders).
144
+
145
+ Args:
146
+ print_func callable[[str], None]: Callable to output the timing message. Defaults to logger.critical.
147
+ format_str str: Format string for the message. Defaults to 'Func {func_name} time used: {time_used:.2f} s'.
148
+
149
+ Returns:
150
+ t.Callable[[GenericFunc], GenericFunc]: A decorator for the target function.
151
+ """
119
152
 
120
153
  def decorator(func: GenericFunc) -> GenericFunc:
121
154
  @wraps(func)
@@ -29,7 +29,8 @@ def main() -> None:
29
29
 
30
30
  log_level = [logging.WARNING, logging.INFO, logging.DEBUG]
31
31
  logging.basicConfig(
32
- level=log_level[min(args.verbose, len(log_level) - 1)], format='%(asctime)s %(levelname)s %(message)s'
32
+ level=log_level[min(args.verbose, len(log_level) - 1)],
33
+ format='%(asctime)s %(levelname)s %(module)s :: %(message)s',
33
34
  )
34
35
 
35
36
  bin_path = args.bin_path or './build'
@@ -8,6 +8,7 @@ import sys
8
8
  import tempfile
9
9
  import zipfile
10
10
  from asyncio.events import AbstractEventLoop
11
+ from dataclasses import dataclass
11
12
  from functools import lru_cache, partial
12
13
 
13
14
  from esptool import get_default_connected_device
@@ -19,6 +20,8 @@ from esptest.tools.http_download import download_file
19
20
  from esptest.utility.parse_bin_path import ParseBinPath
20
21
 
21
22
  logger = get_logger('download_bin')
23
+ FLASH_CRYPT_CNT_PATTERN = re.compile(r'(?:FLASH_CRYPT_CNT|SPI_BOOT_CRYPT_CNT).*\(0b([01]+)')
24
+ SECURE_BOOT_EN_PATTERN = re.compile(r'(?:ABS_DONE_1|SECURE_BOOT_EN).*?\((0b[01]+)\)')
22
25
 
23
26
 
24
27
  @lru_cache()
@@ -72,10 +75,25 @@ def _filter_esptool_log(log: str) -> str:
72
75
  return new_log
73
76
 
74
77
 
78
+ def check_flash_encrypted(efuse_summary: str) -> bool:
79
+ """Check whether flash encryption is enabled from efuse summary."""
80
+ match = FLASH_CRYPT_CNT_PATTERN.search(efuse_summary)
81
+ if match:
82
+ return match.group(1).count('1') % 2 == 1
83
+ return False
84
+
85
+
86
+ def check_secure_boot_enabled(efuse_summary: str) -> bool:
87
+ """Check whether secure boot is enabled from efuse summary."""
88
+ match = SECURE_BOOT_EN_PATTERN.search(efuse_summary)
89
+ if match:
90
+ return match.group(1) == '0b1'
91
+ return False
92
+
93
+
75
94
  class DownBinTool:
76
95
  # RETRY_CNT = 2
77
96
  DEFAULT_BAUD_LIST = [921600, 460800]
78
- FLASH_CRYPT_CNT_PATTERN = re.compile(r'(?:FLASH_CRYPT_CNT|SPI_BOOT_CRYPT_CNT).*\(0b([01]+)')
79
97
 
80
98
  def __init__(
81
99
  self,
@@ -101,12 +119,6 @@ class DownBinTool:
101
119
  self.force_no_stub = force_no_stub
102
120
  self.check_no_stub = check_no_stub
103
121
 
104
- def check_flash_encrypted(self, efuse_summary: str) -> bool:
105
- match = self.FLASH_CRYPT_CNT_PATTERN.search(efuse_summary)
106
- if match:
107
- return match.group(1).count('1') % 2 == 1
108
- return False
109
-
110
122
  def download(self) -> None:
111
123
  efuse_cmd = self.espefuse.split()
112
124
  try:
@@ -116,8 +128,8 @@ class DownBinTool:
116
128
  except subprocess.CalledProcessError as err:
117
129
  logger.error(err.output)
118
130
  raise RuntimeError(f'Failed to get efuse information from {self.port}') from err
119
-
120
- enc_indicator = ' [encrypted]' if self.check_flash_encrypted(summary) else ''
131
+ encrypted_indicator = ' [encrypted]' if check_flash_encrypted(summary) else ''
132
+ secure_boot_indicator = ' [secure_boot]' if check_secure_boot_enabled(summary) else ''
121
133
 
122
134
  download_log = ''
123
135
  for baud in self.baud_list:
@@ -133,16 +145,20 @@ class DownBinTool:
133
145
  chip='auto',
134
146
  )
135
147
  if not esp.IS_STUB: # type: ignore
148
+ logger.debug(f'Add --no-stub for device: {self.port}')
136
149
  args += ['--no-stub'] if '--no-stub' not in args else []
137
150
  args += ['-p', self.port]
138
151
  args += ['-b', f'{baud}']
139
- args += self.bin_parser.flash_bin_args(erase_nvs=self.erase_nvs, encrypted=bool(enc_indicator))
152
+ args += self.bin_parser.flash_bin_args(
153
+ erase_nvs=self.erase_nvs, encrypted=bool(encrypted_indicator), secure_boot=bool(secure_boot_indicator)
154
+ )
140
155
 
141
- logger.info(f'Downloading {self.port}@{baud}{enc_indicator}: {self.bin_path}')
156
+ logger.info(f'Downloading {self.port}@{baud}{encrypted_indicator}{secure_boot_indicator}: {self.bin_path}')
142
157
  logger.debug(f'esptool cmd: {" ".join(args)}')
143
158
  # get return code rather than check
144
159
  ret = subprocess.run(args, capture_output=True, text=True, check=False)
145
160
  if ret.returncode == 0:
161
+ logger.info(f'Download success: [{self.port}@{baud}]')
146
162
  return # succeed
147
163
  # failed
148
164
  download_log += f'esptool cmd failed ({ret.returncode}): ' + ' '.join(args)
@@ -193,3 +209,38 @@ def download_bin_to_ports( # pylint: disable=too-many-positional-arguments
193
209
  check_no_stub: bool = False,
194
210
  ) -> None:
195
211
  asyncio.run(async_download_bin_scheduler(bin_path, ports, erase_nvs, max_workers, force_no_stub, check_no_stub))
212
+
213
+
214
+ @dataclass
215
+ class BinConfig:
216
+ bin_path: str
217
+ port: str
218
+ erase_nvs: bool = True
219
+ force_no_stub: bool = False
220
+ check_no_stub: bool = False
221
+
222
+
223
+ async def async_downbin_scheduler(
224
+ bin_configs: t.List[BinConfig],
225
+ max_workers: int = 0,
226
+ ) -> None:
227
+ max_workers = max_workers or len(bin_configs)
228
+ loop = asyncio.get_running_loop()
229
+ loop.set_default_executor(concurrent.futures.ThreadPoolExecutor(max_workers=max_workers))
230
+
231
+ coroutines = []
232
+ for cfg in bin_configs:
233
+ down_tool = DownBinTool(
234
+ cfg.bin_path,
235
+ cfg.port,
236
+ erase_nvs=cfg.erase_nvs,
237
+ force_no_stub=cfg.force_no_stub,
238
+ check_no_stub=cfg.check_no_stub,
239
+ )
240
+ coroutines.append(_async_download_bin(down_tool, loop))
241
+
242
+ await asyncio.gather(*coroutines)
243
+
244
+
245
+ def download_bins(bin_configs: t.List[BinConfig], max_workers: int = 0) -> None:
246
+ asyncio.run(async_downbin_scheduler(bin_configs, max_workers))
@@ -131,6 +131,7 @@ class ParseBinPath:
131
131
  self._parttool = parttool
132
132
  self._flasher_args: t.Dict[str, t.Any] = {}
133
133
  self._sdkconfig: SDKConfig = SDKConfig()
134
+ self._partition_table_csv_path: str = '' # set when partition_table dir is read-only
134
135
 
135
136
  @property
136
137
  def sdkconfig(self) -> SDKConfig:
@@ -192,21 +193,39 @@ class ParseBinPath:
192
193
  """Check if esptool stub is used"""
193
194
  return bool(self.flasher_args['extra_esptool_args'].get('stub', False))
194
195
 
195
- def _gen_partition_table(self) -> None:
196
+ @property
197
+ def partition_table_csv_path(self) -> Path:
198
+ """Get partition-table.csv path"""
199
+ if self._partition_table_csv_path:
200
+ return Path(self._partition_table_csv_path)
201
+ return Path(self.bin_path) / 'partition_table' / 'partition-table.csv'
202
+
203
+ def _gen_partition_table(self, part_csv: t.Optional[Path] = None) -> None:
196
204
  part_csv = Path(self.bin_path) / 'partition_table' / 'partition-table.csv'
197
205
  part_bin = Path(self.bin_path) / 'partition_table' / 'partition-table.bin'
198
- if self.parttool_path and not part_csv.is_file() and part_bin.is_file():
199
- try:
200
- _cmd = ['python', self.parttool_path, str(part_bin), str(part_csv)]
201
- subprocess.check_call(_cmd, shell=False)
202
- except subprocess.SubprocessError as e:
203
- logger.error(f'Failed to gen partition-table.csv: {str(e)}')
206
+ if part_csv.is_file():
207
+ # already exists
208
+ return
209
+ if not self.parttool_path or not part_bin.is_file():
210
+ logger.error('Can not gen partition-table.csv: parttool_path or partition-table.bin not found')
211
+ return
212
+ if not os.access(part_csv.parent, os.W_OK):
213
+ # partition_table dir is read-only, use tmp dir for .csv
214
+ part_csv = Path(tempfile.mktemp(suffix='.csv'))
215
+ self._partition_table_csv_path = str(part_csv)
216
+ logger.debug(f'Generating partition-table.csv to {part_csv}')
217
+ try:
218
+ _cmd = ['python', self.parttool_path, str(part_bin), str(part_csv)]
219
+ subprocess.check_call(_cmd, shell=False)
220
+ except subprocess.SubprocessError as e:
221
+ logger.error(f'Failed to gen partition-table.csv: {str(e)}')
222
+ raise e
204
223
 
205
224
  @lru_cache()
206
225
  def parse_partitions(self) -> t.List[PartitionInfo]:
207
226
  """Parse partitions from partition-table.csv"""
208
227
  self._gen_partition_table()
209
- partition_table_file = Path(self.bin_path) / 'partition_table' / 'partition-table.csv'
228
+ partition_table_file = self.partition_table_csv_path
210
229
  if not partition_table_file.is_file():
211
230
  raise ValueError('Can not parse partition table')
212
231
  # # Name, Type, SubType, Offset, Size, Flags
@@ -287,17 +306,40 @@ class ParseBinPath:
287
306
  args += ['erase_flash']
288
307
  return args
289
308
 
290
- def flash_bin_args(self, baudrate: int = 0, erase_nvs: bool = True, encrypted: bool = False) -> t.List[str]:
309
+ def _check_secure_boot_match(self, secure_boot: bool) -> None:
310
+ if secure_boot != self.sdkconfig.secure_boot_config:
311
+ msg = (
312
+ f'Secure Boot status mismatch! '
313
+ f'SDKConfig.secure_boot={self.sdkconfig.secure_boot_config}, '
314
+ f'efuse secure_boot_enabled={secure_boot}. '
315
+ f'Refusing to flash bin.'
316
+ )
317
+ raise RuntimeError(msg)
318
+
319
+ def flash_bin_args(
320
+ self,
321
+ baudrate: int = 0,
322
+ erase_nvs: bool = True,
323
+ encrypted: bool = False,
324
+ secure_boot: bool = False,
325
+ ) -> t.List[str]:
291
326
  """Get write_flash args / command for esptool.
292
327
 
293
328
  Args:
294
329
  baudrate (int, optional): baudrate for flashing.
295
330
  erase_nvs (bool, optional): whether to erase nvs partition.
296
331
  encrypted (bool, optional): whether to flash with encryption.
332
+ secure_boot (bool, optional): whether to flash with secure boot.
297
333
  """
298
334
  args = self._write_flash_args_common(baudrate)
299
335
  if encrypted:
300
336
  args += ['--encrypt']
337
+ if secure_boot:
338
+ # Secure Boot blocks writes to protected regions without --force
339
+ # Can't use idf.py flash, can use python -m esptool command in build_log
340
+ args += ['--force']
341
+ # always check secure boot match because efuse will be auto-flashed before idf v6.1 if secure boot is enabled
342
+ self._check_secure_boot_match(secure_boot)
301
343
  for offset, bin_file in self.flasher_args['flash_files'].items():
302
344
  args += [offset, str(Path(self.bin_path) / bin_file)]
303
345
  if erase_nvs:
@@ -0,0 +1,81 @@
1
+ from unittest import mock
2
+
3
+ import esptest.tools.download_bin as download_bin_module
4
+ from esptest.tools.download_bin import BinConfig, download_bin_to_ports, download_bins
5
+
6
+
7
+ # 使用 patch.object(module, ...) 而非 patch('esptest.tools...'),避免 Py 3.7 下 esptest.tools 未加载时的 AttributeError
8
+ # @mock.patch('esptest.tools.download_bin.DownBinTool')
9
+ @mock.patch.object(download_bin_module, 'DownBinTool')
10
+ def test_download_bin_to_ports_calls_down_tool_per_port(
11
+ mock_down_bin_tool: mock.MagicMock,
12
+ ) -> None:
13
+ """download_bin_to_ports 应对每个 port 用同一 bin_path 创建 DownBinTool 并调用 download。"""
14
+ bin_path = '/path/to/bin'
15
+ ports = ['/dev/ttyUSB0', '/dev/ttyUSB1']
16
+ download_bin_to_ports(bin_path, ports, erase_nvs=True, max_workers=2)
17
+ assert mock_down_bin_tool.call_count == 2
18
+ mock_down_bin_tool.assert_any_call(
19
+ bin_path, '/dev/ttyUSB0', erase_nvs=True, force_no_stub=False, check_no_stub=False
20
+ )
21
+ mock_down_bin_tool.assert_any_call(
22
+ bin_path, '/dev/ttyUSB1', erase_nvs=True, force_no_stub=False, check_no_stub=False
23
+ )
24
+ assert mock_down_bin_tool.return_value.download.call_count == 2
25
+
26
+
27
+ @mock.patch.object(download_bin_module, 'DownBinTool')
28
+ def test_download_bins_calls_down_tool_per_config(
29
+ mock_down_bin_tool: mock.MagicMock,
30
+ ) -> None:
31
+ """download_bins 应对每个 BinConfig 创建 DownBinTool 并调用 download。"""
32
+ configs = [
33
+ BinConfig(bin_path='/path/to/bin1', port='/dev/ttyUSB0'),
34
+ BinConfig(bin_path='/path/to/bin2', port='/dev/ttyUSB1', erase_nvs=False),
35
+ ]
36
+ download_bins(configs, max_workers=2)
37
+ assert mock_down_bin_tool.call_count == 2
38
+ mock_down_bin_tool.assert_any_call(
39
+ '/path/to/bin1', '/dev/ttyUSB0', erase_nvs=True, force_no_stub=False, check_no_stub=False
40
+ )
41
+ mock_down_bin_tool.assert_any_call(
42
+ '/path/to/bin2', '/dev/ttyUSB1', erase_nvs=False, force_no_stub=False, check_no_stub=False
43
+ )
44
+ assert mock_down_bin_tool.return_value.download.call_count == 2
45
+
46
+
47
+ @mock.patch.object(download_bin_module, 'DownBinTool')
48
+ def test_download_bins_empty_list(mock_down_bin_tool: mock.MagicMock) -> None:
49
+ """空配置列表时不应创建 DownBinTool,不抛错。"""
50
+ download_bins([], max_workers=1)
51
+ mock_down_bin_tool.assert_not_called()
52
+
53
+
54
+ @mock.patch.object(download_bin_module, 'DownBinTool')
55
+ def test_download_bins_default_max_workers(mock_down_bin_tool: mock.MagicMock) -> None:
56
+ """单配置时创建一次 DownBinTool 并调用 download。"""
57
+ configs = [BinConfig(bin_path='/bin/path', port='/dev/ttyUSB0')]
58
+ download_bins(configs)
59
+ mock_down_bin_tool.assert_called_once_with(
60
+ '/bin/path', '/dev/ttyUSB0', erase_nvs=True, force_no_stub=False, check_no_stub=False
61
+ )
62
+ mock_down_bin_tool.return_value.download.assert_called_once()
63
+
64
+
65
+ @mock.patch.object(download_bin_module, 'DownBinTool')
66
+ def test_download_bins_bin_config_options(mock_down_bin_tool: mock.MagicMock) -> None:
67
+ """BinConfig 的 erase_nvs/force_no_stub/check_no_stub 应传入 DownBinTool。"""
68
+ configs = [
69
+ BinConfig(
70
+ bin_path='/path/bin',
71
+ port='/dev/ttyUSB0',
72
+ erase_nvs=False,
73
+ force_no_stub=True,
74
+ check_no_stub=True,
75
+ ),
76
+ ]
77
+ download_bins(configs, max_workers=1)
78
+ mock_down_bin_tool.assert_called_once_with(
79
+ '/path/bin', '/dev/ttyUSB0', erase_nvs=False, force_no_stub=True, check_no_stub=True
80
+ )
81
+ mock_down_bin_tool.return_value.download.assert_called_once()
@@ -2,6 +2,7 @@ import os
2
2
  import shutil
3
3
  import zipfile
4
4
  from pathlib import Path
5
+ from unittest.mock import patch
5
6
 
6
7
  import pytest
7
8
 
@@ -135,6 +136,36 @@ def test_parse_bin_gen_part(test_bin_path: Path) -> None:
135
136
  assert set([p.name for p in partitions]) == set(['nvs', 'phy_init', 'factory', 'wpa2_cer', 'wpa2_key', 'wpa2_ca'])
136
137
 
137
138
 
139
+ def test_parse_partitions_when_partition_table_dir_read_only(test_bin_path: Path) -> None:
140
+ """When partition_table dir is read-only, csv is generated in a tmp path and parse_partitions still works.
141
+ Use mock for os.access instead of real chmod so the test works in CI/Docker and on any filesystem.
142
+ """
143
+ part_dir = test_bin_path / 'partition_table'
144
+ partition_csv = part_dir / 'partition-table.csv'
145
+ partition_bin = part_dir / 'partition-table.bin'
146
+ assert partition_bin.is_file(), 'test data must have partition-table.bin'
147
+ os.remove(str(partition_csv))
148
+ assert not partition_csv.is_file()
149
+
150
+ real_access = os.access
151
+
152
+ def fake_access(path: Path, mode: int) -> bool:
153
+ if Path(path).resolve() == part_dir.resolve() and mode == os.W_OK:
154
+ return False
155
+ return real_access(path, mode)
156
+
157
+ # Patch os.access where it is used (global os module) to avoid AttributeError
158
+ # when 'esptest.utility' is not visible in the test environment.
159
+ with patch('os.access', side_effect=fake_access):
160
+ parser = ParseBinPath(test_bin_path)
161
+ partitions = parser.parse_partitions()
162
+
163
+ assert not partition_csv.is_file() # csv file should not be created to read-only dir
164
+ assert len(partitions) == 6
165
+ assert set(p.name for p in partitions) == {'nvs', 'phy_init', 'factory', 'wpa2_cer', 'wpa2_key', 'wpa2_ca'}
166
+ assert parser.partition_table_csv_path # attribute should be set
167
+
168
+
138
169
  def test_bin_path_to_dir() -> None:
139
170
  bin_path = bin_path_to_dir(str(TEST_FILE_PATH / 'test-bin.zip'))
140
171
  parse_bin_path = ParseBinPath(bin_path)
File without changes
File without changes
File without changes