esp-test-utils 0.2.3__tar.gz → 0.3.0__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.2.3 → esp_test_utils-0.3.0}/CHANGELOG.md +9 -0
  2. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/PKG-INFO +3 -1
  3. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esp_test_utils.egg-info/PKG-INFO +3 -1
  4. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esp_test_utils.egg-info/SOURCES.txt +3 -0
  5. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esp_test_utils.egg-info/requires.txt +2 -0
  6. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/port/base_port.py +13 -1
  7. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/common/decorators.py +20 -0
  8. esp_test_utils-0.3.0/esptest/devices/switch.py +544 -0
  9. esp_test_utils-0.3.0/esptest/network/mac.py +31 -0
  10. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/scripts/downbin.py +10 -1
  11. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/scripts/list_ports.py +20 -0
  12. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/tools/download_bin.py +33 -6
  13. esp_test_utils-0.3.0/esptest/tools/uart_monitor.py +324 -0
  14. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/pyproject.toml +5 -0
  15. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/adapter/test_Dut.py +21 -0
  16. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/basic/test_decorators.py +25 -1
  17. esp_test_utils-0.3.0/tests/devices/test_switch.py +217 -0
  18. esp_test_utils-0.2.3/esptest/network/mac.py +0 -5
  19. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/.github/.gitkeep +0 -0
  20. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/.github/workflows/pypi-publish.yml +0 -0
  21. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/.gitignore +0 -0
  22. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/.gitlab-ci.yml +0 -0
  23. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/.pre-commit-config.yaml +0 -0
  24. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/CONTRIBUTING.md +0 -0
  25. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/LICENSE +0 -0
  26. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/README.md +0 -0
  27. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/docs/Makefile +0 -0
  28. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/docs/conf.py +0 -0
  29. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/docs/index.rst +0 -0
  30. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/docs/make.bat +0 -0
  31. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esp_test_utils.egg-info/dependency_links.txt +0 -0
  32. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esp_test_utils.egg-info/entry_points.txt +0 -0
  33. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esp_test_utils.egg-info/top_level.txt +0 -0
  34. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/__init__.py +0 -0
  35. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/__main__.py +0 -0
  36. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/__init__.py +0 -0
  37. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/dut/__init__.py +0 -0
  38. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/dut/create_dut.py +0 -0
  39. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/dut/dut_base.py +0 -0
  40. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/dut/esp_dut.py +0 -0
  41. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/dut/esp_mixin.py +0 -0
  42. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/dut/esp_port.py +0 -0
  43. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/dut/mac_mixin.py +0 -0
  44. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/dut/wrapper.py +0 -0
  45. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/port/__init__.py +0 -0
  46. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/port/serial_port.py +0 -0
  47. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/adapter/port/shell_port.py +0 -0
  48. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/all.py +0 -0
  49. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/common/__init__.py +0 -0
  50. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/common/compat_typing.py +0 -0
  51. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/common/data_monitor.py +0 -0
  52. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/common/encoding.py +0 -0
  53. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/common/generator.py +0 -0
  54. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/common/shell.py +0 -0
  55. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/common/timestamp.py +0 -0
  56. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/config/__init__.py +0 -0
  57. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/config/default_config.py +0 -0
  58. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/config/env_config.py +0 -0
  59. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/db/__init__.py +0 -0
  60. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/db/runners.py +0 -0
  61. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/devices/__init__.py +0 -0
  62. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/devices/attenuator.py +0 -0
  63. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/devices/esp_serial.py +0 -0
  64. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/devices/serial_dut.py +0 -0
  65. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/devices/serial_tools.py +0 -0
  66. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/env/__init__.py +0 -0
  67. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/env/base_env.py +0 -0
  68. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/env/wifi_env.py +0 -0
  69. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/esp_console/__init__.py +0 -0
  70. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/esp_console/wifi_cmd.py +0 -0
  71. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/interface/__init__.py +0 -0
  72. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/interface/dut.py +0 -0
  73. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/interface/port.py +0 -0
  74. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/iperf_utility/__init__.py +0 -0
  75. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/iperf_utility/iperf_results.py +0 -0
  76. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/iperf_utility/iperf_test.py +0 -0
  77. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/iperf_utility/iperf_test.test.py +0 -0
  78. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/iperf_utility/line_chart.py +0 -0
  79. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/logger/__init__.py +0 -0
  80. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/logger/logger.py +0 -0
  81. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/network/__init__.py +0 -0
  82. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/network/netif.py +0 -0
  83. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/network/nic.py +0 -0
  84. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/scripts/monitor.py +0 -0
  85. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/scripts/set_att.py +0 -0
  86. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/tools/copy_bin.py +0 -0
  87. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/tools/http_download.py +0 -0
  88. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/tools/pip_check.py +0 -0
  89. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/utility/gen_esp32part.py +0 -0
  90. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/esptest/utility/parse_bin_path.py +0 -0
  91. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/example/jap_test.py +0 -0
  92. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/example/restart_test.py +0 -0
  93. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/setup.cfg +0 -0
  94. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/__init__.py +0 -0
  95. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/adapter/test_shell_port.py +0 -0
  96. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/basic/test_network.py +0 -0
  97. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/conftest.py +0 -0
  98. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/db/test_db_runners.py +0 -0
  99. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/esp_console/_files/wifi_cmd_connected_1.log +0 -0
  100. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/esp_console/_files/wifi_cmd_connected_2.log +0 -0
  101. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/esp_console/conftest.py +0 -0
  102. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/esp_console/test_WifiCmd.py +0 -0
  103. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/iperf_utility/_files/dut_iperf_rx1.log +0 -0
  104. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/iperf_utility/_files/dut_iperf_rx2.log +0 -0
  105. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/iperf_utility/_files/pc_iperf_rx.log +0 -0
  106. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/iperf_utility/_files/pc_iperf_rx2.log +0 -0
  107. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/iperf_utility/test_chart.py +0 -0
  108. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/iperf_utility/test_iperf_results.py +0 -0
  109. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/iperf_utility/test_iperf_util.py +0 -0
  110. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/test_EnvConfig.py +0 -0
  111. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/test_common.py +0 -0
  112. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/test_import.py +0 -0
  113. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/tools/test_download_file.py +0 -0
  114. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/tools/test_pip_check.py +0 -0
  115. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/utility/_files/test-bin.zip +0 -0
  116. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/utility/_files/test-get-baud/ESP32AT-V4.1.1.0/sdkconfig +0 -0
  117. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tests/utility/test_parse_bin_path.py +0 -0
  118. {esp_test_utils-0.2.3 → esp_test_utils-0.3.0}/tools/ci/check_dev_version.py +0 -0
@@ -1,3 +1,12 @@
1
+ ## v0.3.0 (2025-12-10)
2
+
3
+
4
+ - feat: esp-listports support monitor mode
5
+ - feat: add more logs to H3CSwitch
6
+ - feat: esp-downbin support argument --force-no-stub
7
+ - feat: add h3c switch device control
8
+ - feat: add decorator timeit
9
+
1
10
  ## v0.2.3 (2025-11-14)
2
11
 
3
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: esp-test-utils
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: ESP Test Utils
5
5
  Author-email: Chen Yudong <chenyudong@espressif.com>
6
6
  License: Apache License
@@ -228,8 +228,10 @@ Requires-Dist: pyserial
228
228
  Requires-Dist: PyYAML
229
229
  Requires-Dist: pexpect
230
230
  Requires-Dist: pyusb
231
+ Requires-Dist: pyudev
231
232
  Requires-Dist: esptool
232
233
  Requires-Dist: packaging
234
+ Requires-Dist: rich
233
235
  Requires-Dist: sqlalchemy
234
236
  Requires-Dist: typing_extensions; python_version < "3.11"
235
237
  Provides-Extra: idfci
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: esp-test-utils
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: ESP Test Utils
5
5
  Author-email: Chen Yudong <chenyudong@espressif.com>
6
6
  License: Apache License
@@ -228,8 +228,10 @@ Requires-Dist: pyserial
228
228
  Requires-Dist: PyYAML
229
229
  Requires-Dist: pexpect
230
230
  Requires-Dist: pyusb
231
+ Requires-Dist: pyudev
231
232
  Requires-Dist: esptool
232
233
  Requires-Dist: packaging
234
+ Requires-Dist: rich
233
235
  Requires-Dist: sqlalchemy
234
236
  Requires-Dist: typing_extensions; python_version < "3.11"
235
237
  Provides-Extra: idfci
@@ -52,6 +52,7 @@ esptest/devices/attenuator.py
52
52
  esptest/devices/esp_serial.py
53
53
  esptest/devices/serial_dut.py
54
54
  esptest/devices/serial_tools.py
55
+ esptest/devices/switch.py
55
56
  esptest/env/__init__.py
56
57
  esptest/env/base_env.py
57
58
  esptest/env/wifi_env.py
@@ -79,6 +80,7 @@ esptest/tools/copy_bin.py
79
80
  esptest/tools/download_bin.py
80
81
  esptest/tools/http_download.py
81
82
  esptest/tools/pip_check.py
83
+ esptest/tools/uart_monitor.py
82
84
  esptest/utility/gen_esp32part.py
83
85
  esptest/utility/parse_bin_path.py
84
86
  example/jap_test.py
@@ -93,6 +95,7 @@ tests/adapter/test_shell_port.py
93
95
  tests/basic/test_decorators.py
94
96
  tests/basic/test_network.py
95
97
  tests/db/test_db_runners.py
98
+ tests/devices/test_switch.py
96
99
  tests/esp_console/conftest.py
97
100
  tests/esp_console/test_WifiCmd.py
98
101
  tests/esp_console/_files/wifi_cmd_connected_1.log
@@ -3,8 +3,10 @@ pyserial
3
3
  PyYAML
4
4
  pexpect
5
5
  pyusb
6
+ pyudev
6
7
  esptool
7
8
  packaging
9
+ rich
8
10
  sqlalchemy
9
11
 
10
12
  [:python_version < "3.11"]
@@ -26,6 +26,13 @@ NEVER_MATCHED_MAGIC_STRING = 'o6K,Q.(w+~yr~N9R'
26
26
  class ExpectTimeout(TimeoutError):
27
27
  """raise same ExpectTimeout rather than different Exception from different framework"""
28
28
 
29
+ def __init__(self, message: str, data_in_buffer: t.Union[str, bytes] = b'') -> None:
30
+ super().__init__(message)
31
+ self.data_in_buffer: t.Union[str, bytes] = data_in_buffer
32
+
33
+ def __str__(self) -> str:
34
+ return f'{super().__str__()}\n data_in_buffer={repr(self.data_in_buffer)}'
35
+
29
36
 
30
37
  class RawPort(metaclass=abc.ABCMeta):
31
38
  """Define a minimum Dut class, the dut objects should at least support these methods
@@ -408,7 +415,12 @@ class BasePort(PortInterface, t.Generic[T]):
408
415
  try:
409
416
  result = func(self, *args, **kwargs)
410
417
  except self.expect_timeout_exceptions as e:
411
- raise ExpectTimeout(str(e)) from e
418
+ try:
419
+ data_in_buffer = self._pexpect_spawn.before # pylint: disable=protected-access
420
+ except AttributeError:
421
+ data_in_buffer = ''
422
+ self.logger.debug(f'ExpectTimeout: {str(e)}, data_in_buffer={repr(data_in_buffer)}')
423
+ raise ExpectTimeout(str(e), data_in_buffer=data_in_buffer) from e
412
424
  return result
413
425
 
414
426
  return wrap
@@ -109,3 +109,23 @@ def suppress_stdout() -> t.Callable[[GenericFunc], GenericFunc]:
109
109
  return t.cast(GenericFunc, wrapper)
110
110
 
111
111
  return decorator
112
+
113
+
114
+ def timeit(
115
+ print_func: t.Callable[[str], None] = logger.critical,
116
+ format_str: str = 'Func {func_name} time used: {time_used:.2f} s',
117
+ ) -> t.Callable[[GenericFunc], GenericFunc]:
118
+ """Show time used when method is called"""
119
+
120
+ def decorator(func: GenericFunc) -> GenericFunc:
121
+ @wraps(func)
122
+ def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any:
123
+ start_time = time.perf_counter()
124
+ ret = func(*args, **kwargs)
125
+ end_time = time.perf_counter()
126
+ print_func(format_str.format(func_name=func.__name__, time_used=end_time - start_time))
127
+ return ret
128
+
129
+ return t.cast(GenericFunc, wrapper)
130
+
131
+ return decorator
@@ -0,0 +1,544 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ import esptest.common.compat_typing as t
5
+
6
+ from ..adapter.port.shell_port import ShellPort
7
+ from ..all import get_logger
8
+ from ..network.mac import format_mac_to_h3c, normalize_mac
9
+ from ..network.netif import ip_in_network
10
+
11
+ logger = get_logger(__name__)
12
+
13
+ KNOWN_INTERFACE_PREFIXS = [
14
+ 'BAG',
15
+ 'XGE',
16
+ 'HGE',
17
+ 'GE',
18
+ ]
19
+ LINK_TYPE_MAP = {
20
+ 'A': 'access',
21
+ 'T': 'trunk',
22
+ 'H': 'hybrid',
23
+ }
24
+
25
+
26
+ @dataclass
27
+ class SwitchConfig:
28
+ ip: str
29
+ port: int
30
+ login_method: str = 'telnet'
31
+ login_username: str = ''
32
+ login_password: str = ''
33
+ timeout: float = 10
34
+
35
+ def __post_init__(self) -> None:
36
+ if self.login_method not in ['ssh', 'telnet']:
37
+ raise ValueError(f'login_method must be ssh or telnet, got {self.login_method}')
38
+
39
+
40
+ @dataclass
41
+ class VlanInfo:
42
+ id: int # 1-4094
43
+ interface_name: str = '' # Vlan1
44
+ name: str = ''
45
+ type: str = '' # Static
46
+ status: str = '' # UP / DOWN
47
+ ip: str = '' # 10.0.0.1
48
+ mask: str = '' # 255.255.255.0
49
+ description: str = ''
50
+ # TODO: support more fields
51
+ tagged_ports: str = ''
52
+ untagged_ports: str = ''
53
+
54
+ @classmethod
55
+ def parse_interface_brief_line(cls, line: str) -> t.Optional['VlanInfo']:
56
+ """Interface Link Protocol Primary IP Description"""
57
+ if not line.startswith('Vlan'):
58
+ return None
59
+ parts = line.split(maxsplit=4)
60
+ if len(parts) not in [4, 5]:
61
+ # description can be empty
62
+ return None
63
+ vlan_id = int(parts[0].replace('Vlan', ''))
64
+ interface_name = parts[0]
65
+ status = parts[1]
66
+ assert status in ['UP', 'DOWN'], f'Invalid status {status}'
67
+ ip = parts[3]
68
+ # mask is not shown in the output
69
+ description = parts[4] if len(parts) == 5 else ''
70
+ return cls(vlan_id, interface_name, '', '', status, ip, '', description)
71
+
72
+ def parse_vlan_details(self, line: str) -> None:
73
+ """VLAN ID: 1
74
+ VLAN type: Static
75
+ Route interface: Configured
76
+ IPv4 address: 10.0.0.1
77
+ IPv4 subnet mask: 255.255.255.0
78
+ Description: Server
79
+ Name: VLAN 0001
80
+ """
81
+ match = re.search(r'VLAN ID: (\d+)', line)
82
+ if not match or int(match.group(1)) != self.id:
83
+ raise AssertionError(f'VLAN ID does not match current VLAN ID {self.id}')
84
+ # name
85
+ match = re.search(r'Name:\s*([\S ]+)', line)
86
+ assert match
87
+ self.name = match.group(1).strip()
88
+ # ip and mask
89
+ match = re.search(r'IPv4 address:\s*(\d+\.\d+\.\d+\.\d+)', line)
90
+ assert match and match.group(1) == self.ip, f'IP address does not match current IP address {self.ip}'
91
+ match = re.search(r'IPv4 subnet mask:\s*(\d+\.\d+\.\d+\.\d+)', line)
92
+ assert match
93
+ self.mask = match.group(1).strip()
94
+ # other fields
95
+ match = re.search(r'VLAN type:\s*(\w+)', line)
96
+ if match:
97
+ self.type = match.group(1).strip()
98
+ match = re.search(r'Description:\s*([\S ]+)', line)
99
+ if match:
100
+ self.description = match.group(1).strip()
101
+
102
+
103
+ @dataclass
104
+ class PoolInfo:
105
+ name: str
106
+ ip: str = '' # 10.0.0.1
107
+ mask: str = '' # 255.255.255.0
108
+ gateway: str = '' # 10.0.0.1
109
+ dns_list: str = '' # "8.8.8.8 114.114.114.114"
110
+ # needs vlan info
111
+ vlan_id: int = 0 # which vlan the pool belongs to
112
+
113
+ @classmethod
114
+ def parse_pool_info(cls, output: str) -> 'PoolInfo':
115
+ """Pool name: 111
116
+ Network: 10.0.0.0 mask 255.255.254.0
117
+ dns-list 8.8.8.8 114.114.114.114
118
+ expired day 1 hour 0 minute 0 second 0
119
+ gateway-list 10.0.0.1
120
+ static bindings:
121
+ ip-address 10.0.0.10 mask 255.255.254.0
122
+ hardware-address 1122-3344-aabb ethernet
123
+ """
124
+ match = re.search(r'Pool name:\s*(\w+)', output)
125
+ assert match, f'Failed to parse pool name from output: {output}'
126
+ pool_name = match.group(1).strip()
127
+ match = re.search(r'gateway-list\s*(\d+\.\d+\.\d+\.\d+)', output)
128
+ assert match, f'Failed to parse gateway from output: {output}'
129
+ gateway = match.group(1).strip()
130
+ match = re.search(r'Network:\s*(\d+\.\d+\.\d+\.\d+) mask (\d+\.\d+\.\d+\.\d+)', output)
131
+ if match:
132
+ ip = match.group(1).strip()
133
+ mask = match.group(2).strip()
134
+ else:
135
+ logger.warning(
136
+ f'Failed to parse network info from pool {pool_name}, trying parse ip/mask from static bindings'
137
+ )
138
+ ip = gateway.split(' ')[0]
139
+ mask_match = re.search(r'mask (\d+\.\d+\.\d+\.\d+)', output)
140
+ assert mask_match, f'Failed to parse ip/mask from pool: {pool_name}, Please set network config to the pool'
141
+ mask = mask_match.group(1).strip()
142
+ match = re.search(r'dns-list\s*([\d\. ]+)', output)
143
+ assert match
144
+ dns_list = match.group(1).strip()
145
+ return cls(pool_name, ip, mask, gateway, dns_list)
146
+
147
+
148
+ @dataclass
149
+ class InterfaceInfo:
150
+ name: str # XGE1/0/1
151
+ full_name: str = '' # Ten-GigabitEthernet1/0/1
152
+ description: str = ''
153
+ status: str = '' # UP / DOWN
154
+ speed: str = '' # 1000M
155
+ duplex: str = '' # F(a) / A
156
+ link_mode: str = '' # bridge
157
+ link_type: str = '' # access/trunk
158
+ pvid: int = 0 # which vlan the interface belongs to
159
+ permit_vlan: str = '' # 1, 205 to 206
160
+
161
+ @classmethod
162
+ def parse_interface_line(cls, line: str) -> t.Optional['InterfaceInfo']:
163
+ """Interface Link Speed Duplex Type PVID Description"""
164
+ if not any(line.startswith(prefix) for prefix in KNOWN_INTERFACE_PREFIXS):
165
+ return None
166
+ parts = line.split(maxsplit=6)
167
+ if len(parts) not in [6, 7]:
168
+ # description can be empty
169
+ return None
170
+ interface_name = parts[0]
171
+ status = parts[1]
172
+ assert status in ['UP', 'DOWN'], f'Invalid status {status} ({line})'
173
+ speed = parts[2]
174
+ # Duplex: (a)/A - auto; H - half; F - full
175
+ duplex = parts[3]
176
+ if parts[4] in ['A', 'T', 'H']:
177
+ # Type: A - access; T - trunk; H - hybrid
178
+ link_type = LINK_TYPE_MAP[parts[4]]
179
+ else:
180
+ link_type = ''
181
+ try:
182
+ pvid = int(parts[5])
183
+ except ValueError:
184
+ pvid = 0
185
+ # mask is not shown in the output
186
+ description = parts[6] if len(parts) == 7 else ''
187
+ return cls(interface_name, '', description, status, speed, duplex, '', link_type, pvid, '')
188
+
189
+ def parse_interface_details(self, data: str) -> None:
190
+ """
191
+ interface Ten-GigabitEthernet1/0/1
192
+ description test
193
+ port link-mode bridge
194
+ port link-type trunk
195
+ undo port trunk permit vlan 1
196
+ port trunk permit vlan 111 to 112 2000
197
+ port link-aggregation group 1
198
+ """
199
+ # full name
200
+ match = re.search(r'interface\s+(\S+)', data)
201
+ assert match
202
+ self.full_name = match.group(1).strip()
203
+ # vlan
204
+ match = re.search(r'port trunk permit vlan\s+([\S ]+)', data)
205
+ assert match
206
+ self.permit_vlan = match.group(1).strip()
207
+ # link mode
208
+ match = re.search(r'port link-mode\s+(\w+)', data)
209
+ if match:
210
+ self.link_mode = match.group(1).strip()
211
+
212
+
213
+ @dataclass
214
+ class ArpInfo:
215
+ ip: str
216
+ mac: str
217
+ vlan_id: str
218
+ interface: str = ''
219
+ # aging: str = '' # ignored
220
+ type: str = '' # D
221
+ # needs pool info
222
+ pool_name: str = '' # which pool the arp entry belongs to
223
+
224
+ @classmethod
225
+ def parse_arp_line(cls, line: str) -> t.Optional['ArpInfo']:
226
+ """IP address MAC address VLAN/VSI name Interface Aging Type"""
227
+ parts = line.split(maxsplit=5)
228
+ if len(parts) != 6 or not re.match(r'\d+\.\d+\.\d+\.\d+', parts[0]):
229
+ return None
230
+ ip = parts[0]
231
+ mac = normalize_mac(parts[1])
232
+ vlan_id = parts[2]
233
+ interface = parts[3]
234
+ # aging = parts[4] # ignored
235
+ typ = parts[5].strip()
236
+ return cls(ip, mac, vlan_id, interface, typ)
237
+
238
+
239
+ @dataclass
240
+ class StaticBindInfo:
241
+ ip: str
242
+ mask: str
243
+ hardware_address: str
244
+ # needs pool info
245
+ pool_name: str = '' # which pool the arp entry belongs to
246
+
247
+ @property
248
+ def mac(self) -> str:
249
+ return normalize_mac(self.hardware_address)
250
+
251
+
252
+ class H3CSwitch:
253
+ def __init__(self, config: SwitchConfig, log_file: str = '') -> None:
254
+ self.config = config
255
+ self.ip = config.ip
256
+ self.port = config.port
257
+ self.login_method = config.login_method
258
+ self.username = config.login_username
259
+ self.password = config.login_password
260
+ self.timeout = config.timeout
261
+ self.log_file = log_file
262
+ self.session: t.Optional[ShellPort] = None
263
+ self.sysname = ''
264
+ self.need_save = False
265
+ # cache
266
+ self._vlan_info_list: t.List[VlanInfo] = []
267
+ self._interface_info_list: t.List[InterfaceInfo] = []
268
+ self._pool_name_list: t.List[str] = []
269
+ self._pool_info_list: t.List[PoolInfo] = []
270
+ self._arp_info_list: t.List[ArpInfo] = []
271
+ self._static_bind_info_list: t.List[StaticBindInfo] = []
272
+
273
+ def connect(self) -> None:
274
+ """
275
+ Connect to the switch.
276
+ """
277
+ _switch_name = f'H3C-Switch-{self.ip}-{self.port}'
278
+ # Use TERM=xterm to avoid "'xterm-256color': unknown terminal type.".
279
+ if self.login_method == 'telnet':
280
+ self.session = ShellPort(f'TERM=xterm telnet {self.ip}', name=_switch_name, log_file=self.log_file)
281
+ self.session.timeout = self.timeout
282
+ self.session.expect('Login: ')
283
+ self.session.write_line(self.username)
284
+ else:
285
+ self.session = ShellPort(f'TERM=xterm ssh {self.username}@{self.ip}', log_file=self.log_file)
286
+
287
+ self.session.expect('Password:')
288
+ self.session.write_line(self.password)
289
+ match = self.session.expect(re.compile(r'<(\w+)>'))
290
+ self.sysname = match.group(1)
291
+ # Disable pagination
292
+ self.session.write_line('screen-length disable')
293
+ self.session.expect(f'<{self.sysname}>')
294
+ self.session.write_line('system-view')
295
+ self.session.expect(f'[{self.sysname}]')
296
+ logger.info(f'Connected to switch: {self.ip}:{self.port}')
297
+
298
+ def disconnect(self) -> None:
299
+ """Disconnect from the switch, and save the configuration if needed."""
300
+ if self.session:
301
+ if self.need_save:
302
+ self.save()
303
+ self.session.close()
304
+ logger.info(f'Disconnected from switch: {self.ip}:{self.port}')
305
+ self.session = None
306
+ self.sysname = ''
307
+
308
+ def save(self) -> None:
309
+ """Save the configuration of the switch."""
310
+ if self.session:
311
+ self.session.write_line('save f')
312
+ self.session.expect('successfully.')
313
+ self.session.expect(self.sysname)
314
+ self.need_save = False
315
+ logger.info('Switch configuration saved successfully.')
316
+
317
+ def reset_cache(self) -> None:
318
+ """Reset the cache of the switch."""
319
+ self._vlan_info_list = []
320
+ self._interface_info_list = []
321
+ self._pool_name_list = []
322
+ self._pool_info_list = []
323
+ self._arp_info_list = []
324
+ self._static_bind_info_list = []
325
+
326
+ def __enter__(self) -> 'H3CSwitch':
327
+ self.connect()
328
+ return self
329
+
330
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
331
+ self.disconnect()
332
+
333
+ def execute_command(self, command: str, timeout: float = -1) -> str:
334
+ """Execute a command on the switch and return the result."""
335
+ if timeout == -1:
336
+ timeout = self.timeout
337
+ if self.session:
338
+ # ensure internal buffers are flushed before sending a command
339
+ self.session.flush_data()
340
+ self.session.write_line(command)
341
+ # escape special chars for regex matching
342
+ # limit the length to 20 chars, because H3C echo may insert new line
343
+ _command_escaped = re.escape(command[:20])
344
+ match = self.session.expect(
345
+ re.compile(rf'({_command_escaped}[\s\S]+[\[<]{self.sysname}\S*[\]>])'), timeout=timeout
346
+ )
347
+ # return the captured output between the echoed command and the prompt
348
+ return match.group(1).strip()
349
+ return ''
350
+
351
+ def system_view(self) -> bool:
352
+ """Enter system view of the switch."""
353
+ if not self.session:
354
+ return False
355
+ self.session.flush_data()
356
+ self.session.write_line('')
357
+ match = self.session.expect(re.compile(rf'([\[<]{self.sysname}[-\w]*[\]>])'))
358
+ data = match.group(1)
359
+ if data.startswith('<'):
360
+ self.session.write_line('system-view')
361
+ self.session.expect(f'[{self.sysname}]')
362
+ return True
363
+ if data == f'[{self.sysname}]':
364
+ # already in system view
365
+ return True
366
+ assert data.startswith(f'[{self.sysname}')
367
+ self.session.write_line('qu')
368
+ self.session.expect(f'[{self.sysname}]')
369
+ return True
370
+
371
+ def get_vlan_info(self) -> t.List[VlanInfo]:
372
+ """Get VLAN (interface) information from the switch."""
373
+ if self._vlan_info_list:
374
+ return self._vlan_info_list
375
+ # show vlan interfaces
376
+ command = 'display interface Vlan-interface brief'
377
+ output = self.execute_command(command)
378
+ self._vlan_info_list = []
379
+ for line in output.splitlines():
380
+ if not line.startswith('Vlan'):
381
+ continue
382
+ new_vlan = VlanInfo.parse_interface_brief_line(line)
383
+ assert new_vlan
384
+ output = self.execute_command(f'display vlan {new_vlan.id}')
385
+ new_vlan.parse_vlan_details(output)
386
+ self._vlan_info_list.append(new_vlan)
387
+ logger.info(f'Get vlan [interface] info: {len(self._vlan_info_list)} vlans')
388
+ return self._vlan_info_list
389
+
390
+ def get_pool_name_list(self) -> t.List[str]:
391
+ """Get pool name list from the switch."""
392
+ if self._pool_name_list:
393
+ return self._pool_name_list
394
+ # show pool
395
+ command = 'display dhcp server pool | include name'
396
+ output = self.execute_command(command)
397
+ self._pool_name_list = []
398
+ for match in re.finditer(r'Pool name:\s*(\S+)', output):
399
+ pool_name = match.group(1)
400
+ self._pool_name_list.append(pool_name)
401
+ return self._pool_name_list
402
+
403
+ def get_pool_info(self) -> t.List[PoolInfo]:
404
+ """Get pool information from the switch."""
405
+ if self._pool_info_list:
406
+ return self._pool_info_list
407
+ # show pool
408
+ pool_names = self.get_pool_name_list()
409
+ for pool_name in pool_names:
410
+ output = self.execute_command(f'display dhcp server pool {pool_name}')
411
+ new_pool = PoolInfo.parse_pool_info(output)
412
+ assert new_pool
413
+ # try to add vlan_id to the pool
414
+ for vlan in self.get_vlan_info():
415
+ if vlan.ip in new_pool.gateway:
416
+ new_pool.vlan_id = vlan.id
417
+ break
418
+ self._pool_info_list.append(new_pool)
419
+ logger.info(f'Get pool list: {len(self._pool_info_list)} pools')
420
+ return self._pool_info_list
421
+
422
+ def get_interface_info(self, detail: bool = False) -> t.List[InterfaceInfo]:
423
+ """Get interface information from the switch."""
424
+ if self._interface_info_list:
425
+ return self._interface_info_list
426
+ # show pool
427
+ command = 'display interface brief'
428
+ output = self.execute_command(command)
429
+ self._interface_info_list = []
430
+ for line in output.splitlines():
431
+ new_interface = InterfaceInfo.parse_interface_line(line)
432
+ if new_interface:
433
+ if detail:
434
+ # add interface vlan info (display this in interface view)
435
+ self.system_view()
436
+ self.execute_command(f'interface {new_interface.name}')
437
+ output = self.execute_command('display this')
438
+ new_interface.parse_interface_details(output)
439
+ self.system_view()
440
+ self._interface_info_list.append(new_interface)
441
+ logger.info(f'Get interface list: {len(self._interface_info_list)} interfaces')
442
+ return self._interface_info_list
443
+
444
+ def get_arp_info(self) -> t.List[ArpInfo]:
445
+ """Get ARP information from the switch."""
446
+ if self._arp_info_list:
447
+ return self._arp_info_list
448
+ # show pool
449
+ command = 'display arp'
450
+ output = self.execute_command(command)
451
+ self._arp_info_list = []
452
+ for line in output.splitlines():
453
+ new_arp = ArpInfo.parse_arp_line(line)
454
+ if new_arp:
455
+ self._arp_info_list.append(new_arp)
456
+ logger.info(f'Get ARP list: {len(self._arp_info_list)} ARP entries')
457
+ return self._arp_info_list
458
+
459
+ def get_static_bind_info(self) -> t.List[StaticBindInfo]:
460
+ """Get static bind information from the switch."""
461
+ if self._static_bind_info_list:
462
+ return self._static_bind_info_list
463
+ self._static_bind_info_list = []
464
+ pattern = re.compile(r'ip-address\s+([\d\.]+)\s+mask\s+([\d\.]+)\s+hardware-address\s+(\S+)\s')
465
+ for pool in self.get_pool_name_list():
466
+ command = f'display dhcp server pool {pool}'
467
+ output = self.execute_command(command)
468
+ for match in pattern.finditer(output):
469
+ ip_address = match.group(1)
470
+ mask = match.group(2)
471
+ hardware_address = match.group(3)
472
+ new_bind = StaticBindInfo(ip_address, mask, hardware_address, pool)
473
+ self._static_bind_info_list.append(new_bind)
474
+ logger.info(f'Get static bind list: {len(self._static_bind_info_list)} static binds')
475
+ return self._static_bind_info_list
476
+
477
+ def get_pool_by_ip(self, ip_address: str) -> PoolInfo:
478
+ """Get pool name by IP address."""
479
+ for pool in self.get_pool_info():
480
+ if ip_in_network(ip_address, f'{pool.ip}/{pool.mask}'):
481
+ return pool
482
+ raise ValueError(f'IP address {ip_address} not found in any pool')
483
+
484
+ def get_arp_info_by_ip(self, ip_address: str) -> ArpInfo:
485
+ """Get ARP information by IP address."""
486
+ if self._arp_info_list:
487
+ for arp_info in self._arp_info_list:
488
+ if arp_info.ip == ip_address:
489
+ return arp_info
490
+ output = self.execute_command(f'display arp {ip_address}')
491
+ for line in output.splitlines():
492
+ if line.startswith(ip_address):
493
+ arp_info = ArpInfo.parse_arp_line(line) # type: ignore
494
+ if arp_info:
495
+ return arp_info
496
+ raise ValueError(f'IP address {ip_address} not found in ARP table')
497
+
498
+ def add_one_static_bind( # pylint: disable=too-many-positional-arguments
499
+ self,
500
+ ip_address: str,
501
+ hardware_address: str = '',
502
+ mask: str = '',
503
+ pool_name: str = '',
504
+ remove_existing: bool = False,
505
+ ) -> bool:
506
+ """Add static bind information to the switch.
507
+
508
+ Args:
509
+ ip_address: IP address to bind.
510
+ hardware_address: Hardware address to bind.
511
+ mask: Subnet mask.
512
+ pool_name: Pool name.
513
+ remove_existing: Remove existing bind information for the IP address.
514
+ """
515
+ if pool_name:
516
+ assert mask, 'Mask is required when pool_name is specified'
517
+ assert pool_name in self.get_pool_name_list(), f'Pool {pool_name} not found on this switch'
518
+ else:
519
+ pool = self.get_pool_by_ip(ip_address)
520
+ pool_name = pool.name
521
+ mask = pool.mask
522
+ if not hardware_address:
523
+ hardware_address = self.get_arp_info_by_ip(ip_address).mac
524
+ hardware_address = format_mac_to_h3c(hardware_address)
525
+ result = True
526
+ self.system_view()
527
+ command = f'dhcp server ip-pool {pool_name}'
528
+ self.execute_command(command)
529
+ try:
530
+ if remove_existing:
531
+ command = f'undo static-bind ip-address {ip_address}'
532
+ self.execute_command(command)
533
+ logger.info(f'Bind static dhcp {ip_address} {mask} {hardware_address} to pool {pool_name}.')
534
+ command = f'static-bind ip-address {ip_address} mask {mask} hardware-address {hardware_address}'
535
+ output = self.execute_command(command)
536
+ if 'The IP address has already been bound' in output:
537
+ logger.error(f'IP address {ip_address} has already been bound, pool:{pool_name}')
538
+ result = False
539
+ except TimeoutError as e:
540
+ logger.error(f'Failed to bind {ip_address} {mask} {hardware_address}, pool:{pool_name}, error:{str(e)}')
541
+ result = False
542
+ self.need_save = bool(result)
543
+ self.system_view() # return to system view
544
+ return result