lecore 0.2.3__tar.gz → 0.2.6__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 (87) hide show
  1. {lecore-0.2.3/src/lecore.egg-info → lecore-0.2.6}/PKG-INFO +3 -2
  2. {lecore-0.2.3 → lecore-0.2.6}/README.md +56 -55
  3. {lecore-0.2.3 → lecore-0.2.6}/pyproject.toml +5 -2
  4. lecore-0.2.6/src/lecore/DeviceTests/Base.py +284 -0
  5. lecore-0.2.6/src/lecore/DeviceTests/LeBinTests.py +67 -0
  6. lecore-0.2.6/src/lecore/DeviceTests/ModbusTests.py +80 -0
  7. lecore-0.2.6/src/lecore/DeviceTests/Plugin.py +20 -0
  8. lecore-0.2.6/src/lecore/DeviceTests/__init__.py +15 -0
  9. lecore-0.2.6/src/lecore/FwBuild.py +154 -0
  10. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/LeBin/RegisterMap.py +361 -361
  11. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/LeBin/SerialCom.py +5 -3
  12. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/LeBin/UpgradeFirmware.py +97 -83
  13. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/LeBin/VisualLeBin.py +75 -19
  14. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/QUICKSTART.md +136 -98
  15. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Scaffold.py +142 -6
  16. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/AppLogging.py +139 -139
  17. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/MbClient.py +37 -11
  18. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/RegMap.py +420 -420
  19. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/VisualMbApp.py +255 -238
  20. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/__init__.py +2 -2
  21. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/__main__.py +89 -67
  22. {lecore-0.2.3 → lecore-0.2.6/src/lecore.egg-info}/PKG-INFO +3 -2
  23. {lecore-0.2.3 → lecore-0.2.6}/src/lecore.egg-info/SOURCES.txt +6 -0
  24. {lecore-0.2.3 → lecore-0.2.6}/src/lecore.egg-info/entry_points.txt +3 -0
  25. {lecore-0.2.3 → lecore-0.2.6}/src/lecore.egg-info/requires.txt +1 -1
  26. {lecore-0.2.3 → lecore-0.2.6}/LICENSE +0 -0
  27. {lecore-0.2.3 → lecore-0.2.6}/MANIFEST.in +0 -0
  28. {lecore-0.2.3 → lecore-0.2.6}/setup.cfg +0 -0
  29. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/DeviceBase.py +0 -0
  30. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/LeBinDev.py +0 -0
  31. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/ModbusDev.py +0 -0
  32. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/PhaseDet/ComSettings.json +0 -0
  33. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/PhaseDet/PhaseDet.py +0 -0
  34. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/PhaseDet/PhaseDetRegs.py +0 -0
  35. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/PhaseDet/PhaseDet_Modbus.json +0 -0
  36. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/PhaseDet/UpgradeSettings.json +0 -0
  37. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/PhaseDet/VisualSettings.json +0 -0
  38. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/PhaseDet/__init__.py +0 -0
  39. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/ComSettings.json +0 -0
  40. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/Rftb.py +0 -0
  41. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/RftbLeBin.py +0 -0
  42. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/UpgradeSettings.json +0 -0
  43. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/VisualSettings.json +0 -0
  44. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/__init__.py +0 -0
  45. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/rftbRegs.py +0 -0
  46. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/rftb_Modbus.json +0 -0
  47. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Rftb/rftb_registers.json +0 -0
  48. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/RtdEmul/ComSettings.json +0 -0
  49. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/RtdEmul/RtdEmul.py +0 -0
  50. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/RtdEmul/RtdEmulRegs.py +0 -0
  51. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/RtdEmul/RtdEmul_Modbus.json +0 -0
  52. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/RtdEmul/UpgradeSettings.json +0 -0
  53. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/RtdEmul/VisualSettings.json +0 -0
  54. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/RtdEmul/__init__.py +0 -0
  55. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/Validation.py +0 -0
  56. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/VmsRtd/ComSettings.json +0 -0
  57. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/VmsRtd/UpgradeSettings.json +0 -0
  58. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/VmsRtd/VMS_RTDRegs.py +0 -0
  59. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/VmsRtd/VMS_RTD_Modbus.json +0 -0
  60. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/VmsRtd/VisualSettings.json +0 -0
  61. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/VmsRtd/VmsRtd.py +0 -0
  62. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/VmsRtd/__init__.py +0 -0
  63. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Devices/__init__.py +0 -0
  64. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Doc/ModbusTable.py +0 -0
  65. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Doc/__init__.py +0 -0
  66. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Fg320/Fg320.py +0 -0
  67. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Fg320/__init__.py +0 -0
  68. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/LeBin/__init__.py +0 -0
  69. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/Looger.py +0 -0
  70. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/TestFrame/CoreTest.py +0 -0
  71. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/TestFrame/ModbusUnitTest.py +0 -0
  72. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/TestFrame/RtuClient.py +0 -0
  73. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/TestFrame/Runner.py +0 -0
  74. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/TestFrame/TestUtils.py +0 -0
  75. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/TestFrame/__init__.py +0 -0
  76. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/Crc32.py +0 -0
  77. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/HelpAbout.py +0 -0
  78. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/MbUpgrade.py +0 -0
  79. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/ReadWrite.py +0 -0
  80. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/TextMbApp.py +0 -0
  81. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/TextMbAppCss.tcss +0 -0
  82. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/VmSettings.py +0 -0
  83. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/VisualModbus/__init__.py +0 -0
  84. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/_version.py +0 -0
  85. {lecore-0.2.3 → lecore-0.2.6}/src/lecore/py.typed +0 -0
  86. {lecore-0.2.3 → lecore-0.2.6}/src/lecore.egg-info/dependency_links.txt +0 -0
  87. {lecore-0.2.3 → lecore-0.2.6}/src/lecore.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lecore
3
- Version: 0.2.3
3
+ Version: 0.2.6
4
4
  Summary: Logic Elements core utilities
5
5
  Author-email: Jan Bartovský <jan.bartovsky@logicelements.cz>
6
6
  License-Expression: MIT
@@ -19,7 +19,7 @@ Requires-Python: >=3.9
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: pyserial
22
- Requires-Dist: pymodbus<4,>=2.5
22
+ Requires-Dist: pymodbus>=2.5
23
23
  Requires-Dist: unittest-xml-reporting
24
24
  Provides-Extra: extra
25
25
  Requires-Dist: FreeSimpleGUI; extra == "extra"
@@ -81,6 +81,7 @@ New device class (in the directory with the register map): `python -m lecore new
81
81
  - [Device API](doc/api.md) - Modbus and LeBin device: read, write, firmware upgrade
82
82
  - [User interfaces](doc/user-interfaces.md) - graphical and terminal register browser
83
83
  - [Devices](doc/devices.md) - RTD Emulator, VMS-1502 RTD Meter, Phase Detector, FG-320
84
+ - [Firmware post-build](doc/fwbuild.md) - FW information block and CRC (replaces BinBuild.exe)
84
85
  - [Build and release](doc/build.md)
85
86
  - [Looger](doc/looger.md)
86
87
 
@@ -1,55 +1,56 @@
1
- # LE core package
2
-
3
- [![CI](https://github.com/LogicElements/lecore/actions/workflows/ci.yml/badge.svg)](https://github.com/LogicElements/lecore/actions/workflows/ci.yml)
4
- [![PyPI](https://img.shields.io/pypi/v/lecore)](https://pypi.org/project/lecore/)
5
- [![Python](https://img.shields.io/pypi/pyversions/lecore)](https://pypi.org/project/lecore/)
6
- [![License](https://img.shields.io/pypi/l/lecore)](LICENSE)
7
-
8
- Core Python utilities for Logic Elements:
9
- - Looger client
10
- - Proprietary LeBin protocol
11
- - Proprietary Modbus RTU register map handling - VisualModbus
12
- - Test frame
13
- - Logic Elements devices
14
-
15
- ## Install
16
-
17
- Requires Python 3.9 or newer.
18
-
19
- ```
20
- pip install lecore
21
- pip install --upgrade lecore
22
- ```
23
-
24
- Optional extras: `lecore[extra]` (graphical UI, Word documentation) and `lecore[tui]` (terminal UI).
25
-
26
- ## Usage
27
-
28
- ```python
29
- import lecore.Devices.RtdEmul as RE
30
-
31
- # Create RTD emulator object and open communication at COM2 (default is COM1)
32
- rtd = RE.RtdEmul()
33
- rtd.open(comport='COM2')
34
-
35
- # Read serial number
36
- sn = rtd.read(RE.Reg.FACT_SERIAL_NUMBER)
37
-
38
- # Write emulated temperature at output 1
39
- rtd.write(RE.Reg.EMUL_TEMPERATURE_1, 30.5)
40
- ```
41
-
42
- New device class (in the directory with the register map): `python -m lecore new-device MyDevice`, quick guide: `python -m lecore guide`.
43
-
44
- ## Documentation
45
-
46
- - [Creating a new device](doc/new-device.md)
47
- - [Device API](doc/api.md) - Modbus and LeBin device: read, write, firmware upgrade
48
- - [User interfaces](doc/user-interfaces.md) - graphical and terminal register browser
49
- - [Devices](doc/devices.md) - RTD Emulator, VMS-1502 RTD Meter, Phase Detector, FG-320
50
- - [Build and release](doc/build.md)
51
- - [Looger](doc/looger.md)
52
-
53
- ## License
54
-
55
- MIT, see [LICENSE](LICENSE).
1
+ # LE core package
2
+
3
+ [![CI](https://github.com/LogicElements/lecore/actions/workflows/ci.yml/badge.svg)](https://github.com/LogicElements/lecore/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/lecore)](https://pypi.org/project/lecore/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/lecore)](https://pypi.org/project/lecore/)
6
+ [![License](https://img.shields.io/pypi/l/lecore)](LICENSE)
7
+
8
+ Core Python utilities for Logic Elements:
9
+ - Looger client
10
+ - Proprietary LeBin protocol
11
+ - Proprietary Modbus RTU register map handling - VisualModbus
12
+ - Test frame
13
+ - Logic Elements devices
14
+
15
+ ## Install
16
+
17
+ Requires Python 3.9 or newer.
18
+
19
+ ```
20
+ pip install lecore
21
+ pip install --upgrade lecore
22
+ ```
23
+
24
+ Optional extras: `lecore[extra]` (graphical UI, Word documentation) and `lecore[tui]` (terminal UI).
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ import lecore.Devices.RtdEmul as RE
30
+
31
+ # Create RTD emulator object and open communication at COM2 (default is COM1)
32
+ rtd = RE.RtdEmul()
33
+ rtd.open(comport='COM2')
34
+
35
+ # Read serial number
36
+ sn = rtd.read(RE.Reg.FACT_SERIAL_NUMBER)
37
+
38
+ # Write emulated temperature at output 1
39
+ rtd.write(RE.Reg.EMUL_TEMPERATURE_1, 30.5)
40
+ ```
41
+
42
+ New device class (in the directory with the register map): `python -m lecore new-device MyDevice`, quick guide: `python -m lecore guide`.
43
+
44
+ ## Documentation
45
+
46
+ - [Creating a new device](doc/new-device.md)
47
+ - [Device API](doc/api.md) - Modbus and LeBin device: read, write, firmware upgrade
48
+ - [User interfaces](doc/user-interfaces.md) - graphical and terminal register browser
49
+ - [Devices](doc/devices.md) - RTD Emulator, VMS-1502 RTD Meter, Phase Detector, FG-320
50
+ - [Firmware post-build](doc/fwbuild.md) - FW information block and CRC (replaces BinBuild.exe)
51
+ - [Build and release](doc/build.md)
52
+ - [Looger](doc/looger.md)
53
+
54
+ ## License
55
+
56
+ MIT, see [LICENSE](LICENSE).
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "lecore"
7
7
  description = "Logic Elements core utilities"
8
- version = "0.2.3"
8
+ version = "0.2.6"
9
9
  authors = [
10
10
  { name="Jan Bartovský", email="jan.bartovsky@logicelements.cz" },
11
11
  ]
@@ -29,13 +29,16 @@ keywords = ["logicelements", "looger", "development"]
29
29
  requires-python = ">=3.9"
30
30
  dependencies = [
31
31
  "pyserial",
32
- "pymodbus>=2.5,<4",
32
+ "pymodbus>=2.5",
33
33
  "unittest-xml-reporting",
34
34
  ]
35
35
 
36
36
  [project.scripts]
37
37
  lecore = "lecore.__main__:main"
38
38
 
39
+ [project.entry-points.pytest11]
40
+ lecore = "lecore.DeviceTests.Plugin"
41
+
39
42
  [project.optional-dependencies]
40
43
  extra = [
41
44
  "FreeSimpleGUI",
@@ -0,0 +1,284 @@
1
+ """
2
+ Protocol independent part of the device communication test suite
3
+ """
4
+ import math
5
+ import random
6
+ import string
7
+ from typing import Any, Callable, Dict, Optional, Tuple
8
+
9
+ import pytest
10
+
11
+ #: Name of the register injected into the loaded register map by the unknown register test
12
+ UNKNOWN_NAME = '__LECORE_UNKNOWN__'
13
+
14
+ _STRING_FORMATS = ('STRING',)
15
+ _FLOAT_FORMATS = ('FLOAT', 'FLOAT32')
16
+
17
+
18
+ def _limits(reg: dict) -> Optional[Tuple[float, float]]:
19
+ """
20
+ Min/Max limits of register, None when not defined (both zero or not numbers)
21
+ """
22
+ low, high = reg.get('Min', 0), reg.get('Max', 0)
23
+ numbers = all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (low, high))
24
+ if not numbers or (low == 0 and high == 0):
25
+ return None
26
+ return low, high
27
+
28
+
29
+ def random_value(reg: dict, fmt: str, bits: int) -> Any:
30
+ """
31
+ Random value valid for writing into the register, different from its current value when possible
32
+ :param reg: Register definition from register map
33
+ :param fmt: Register format, e.g., 'INT', 'ENUM', 'FLOAT', 'FLOAT32', 'STRING'
34
+ :param bits: Raw bit width of the register
35
+ :return: Value passing check_reg_value()
36
+ """
37
+ current = str(reg.get('Value'))
38
+ if fmt in _STRING_FORMATS:
39
+ def gen():
40
+ return ''.join(random.choice(string.ascii_letters) for _ in range(random.randint(1, min(bits // 8, 8))))
41
+ elif fmt == 'ENUM' and reg.get('EnumValue'):
42
+ choices = [c for c in reg['EnumValue'] if str(c) != current] or list(reg['EnumValue'])
43
+
44
+ def gen():
45
+ return random.choice(choices)
46
+ elif fmt in _FLOAT_FORMATS:
47
+ low, high = _limits(reg) or (-1000, 1000)
48
+ # Halves are exact in binary, so the value survives the 0.1 resolution and float32 conversions
49
+ low2, high2 = math.ceil(low * 2), math.floor(high * 2)
50
+ if fmt == 'FLOAT':
51
+ low2, high2 = max(low2, -6553), min(high2, 6553)
52
+
53
+ def gen():
54
+ return random.randint(low2, high2) / 2
55
+ else:
56
+ low, high = _limits(reg) or (0, (1 << bits) - 1)
57
+ low, high = max(math.ceil(low), 0), min(math.floor(high), (1 << bits) - 1)
58
+
59
+ def gen():
60
+ return random.randint(low, high)
61
+ value = gen()
62
+ for _ in range(10):
63
+ if str(value) != current:
64
+ break
65
+ value = gen()
66
+ return value
67
+
68
+
69
+ def pick_rejected_writes(regs: Dict[str, dict], is_read_only: Callable[[dict], bool]) \
70
+ -> Tuple[Optional[str], Optional[str]]:
71
+ """
72
+ Find registers whose write is rejected locally by validation
73
+ :param regs: Registers of the map by name
74
+ :param is_read_only: Function deciding whether register is read-only
75
+ :return: (read-only register name, writable numeric register name with Min/Max limits), None when not found
76
+ """
77
+ read_only = next((n for n, r in regs.items() if n != UNKNOWN_NAME and is_read_only(r)), None)
78
+ limited = next((n for n, r in regs.items() if n != UNKNOWN_NAME and not is_read_only(r)
79
+ and r.get('Format', r.get('VarType')) not in _STRING_FORMATS and _limits(r)), None)
80
+ return read_only, limited
81
+
82
+
83
+ #: Fixture of a test environment (HIL platform) providing an opened device for classes with hil_signal
84
+ HIL_FIXTURE = 'hil_lecore_device'
85
+
86
+
87
+ def _device(request):
88
+ """
89
+ Device opened once per test class, by the test environment (hil_signal) or by create_device()
90
+ """
91
+ if getattr(request.cls, 'hil_signal', None):
92
+ try:
93
+ dev = request.getfixturevalue(HIL_FIXTURE)
94
+ except LookupError: # pytest.FixtureLookupError, the HIL platform is not installed
95
+ dev = None
96
+ if dev is not None: # None: no HIL station selected, the device is opened here
97
+ yield dev # the environment owns the device and closes it
98
+ return
99
+ dev = request.cls.create_device()
100
+ yield dev
101
+ dev.close()
102
+
103
+
104
+ class DeviceTestsBase:
105
+ """
106
+ Communication tests of a real device, subclass it (class name starting with Test) and set class attributes
107
+ """
108
+ pytestmark = [pytest.mark.hardware]
109
+
110
+ #: Device class (ModbusDev or LeBinDev subclass) instantiated by create_device()
111
+ device_class = None
112
+ #: Arguments of device open(), e.g., {'comport': 'COM5'}; empty uses ComSettings*.json
113
+ open_kwargs: dict = {}
114
+ #: Register safe to write by the read-back test, None skips the test
115
+ test_register: Optional[str] = None
116
+ #: Registers not read by the read-all test
117
+ exclude: list = []
118
+ #: Firmware binary for the upgrade test, None skips the test
119
+ firmware = None
120
+ #: Time the device takes to reboot after upgrade in seconds
121
+ reboot_time: float = 5
122
+ #: DUT signal name (hil/dut.yaml) of the device when run on a HIL station, None opens it by create_device()
123
+ hil_signal: Optional[str] = None
124
+ #: DUT power signal switched on by HIL for the whole test class, None leaves the power as it is
125
+ hil_supply: Optional[str] = None
126
+ #: Time in seconds HIL waits after switching hil_supply on before opening the device
127
+ hil_boot_s: float = 0
128
+
129
+ #: Name of the attribute configuring the unknown register, used in failure hints
130
+ _unknown_hint = ''
131
+
132
+ @classmethod
133
+ def create_device(cls):
134
+ """
135
+ Create and open the device, override it for an exact device definition
136
+ :return: Opened device
137
+ """
138
+ if cls.device_class is None:
139
+ pytest.fail(f"{cls.__name__}: set device_class or override create_device()")
140
+ dev = cls.device_class()
141
+ if dev.open(**cls.open_kwargs) is False:
142
+ dev.close()
143
+ pytest.fail(f"{cls.__name__}: cannot open {cls.device_class.__name__} with {cls.open_kwargs or 'defaults'}"
144
+ f" ({_port_name(dev)})")
145
+ return dev
146
+
147
+ # Static fixture taking the test class from request: an instance method class fixture is deprecated
148
+ # in pytest 9 and a classmethod fixture fails on Python 3.9 (classmethod has no __name__)
149
+ device = staticmethod(pytest.fixture(scope="class", name="device")(_device))
150
+
151
+ # Protocol hooks
152
+
153
+ def _inject_unknown(self, device) -> str:
154
+ raise NotImplementedError
155
+
156
+ def _remove_unknown(self, device, name: str) -> None:
157
+ raise NotImplementedError
158
+
159
+ def _swap_client(self, device, client) -> Any:
160
+ raise NotImplementedError
161
+
162
+ def _stub_client(self, device):
163
+ raise NotImplementedError
164
+
165
+ def _value_args(self, reg: dict) -> Tuple[str, int]:
166
+ raise NotImplementedError
167
+
168
+ def _is_read_only(self, reg: dict) -> bool:
169
+ raise NotImplementedError
170
+
171
+ # Helpers
172
+
173
+ def _probe_name(self, device) -> str:
174
+ """
175
+ Register read to check the device answers
176
+ """
177
+ names = device.register_names()
178
+ return self.test_register if self.test_register in names else names[0]
179
+
180
+ def _assert_reads(self, device, what: str = "") -> None:
181
+ name = self._probe_name(device)
182
+ assert device.read(name) is not None, f"Device does not answer reading {name}{what}"
183
+
184
+ # Tests
185
+
186
+ def test_01_read(self, device):
187
+ self._assert_reads(device)
188
+
189
+ def test_02_write_read_back(self, device):
190
+ name = self.test_register
191
+ if name is None:
192
+ pytest.skip("test_register is not set")
193
+ regs = device._reg_dict()
194
+ if name not in regs:
195
+ pytest.fail(f"test_register {name} not found in the register map")
196
+ reg = regs[name]
197
+ if self._is_read_only(reg):
198
+ pytest.fail(f"test_register {name} is read-only")
199
+ original = device.read(name)
200
+ assert original is not None, f"Device does not answer reading {name}"
201
+ fmt, bits = self._value_args(reg)
202
+ value = random_value(reg, fmt, bits)
203
+ restored = None
204
+ try:
205
+ assert device.write(name, value) is not None, f"Write of {value!r} into {name} failed"
206
+ back = device.read(name)
207
+ if isinstance(value, float):
208
+ assert back is not None and math.isclose(back, value, abs_tol=1e-3), \
209
+ f"Read back {back!r} of {name} differs from written {value!r}"
210
+ else:
211
+ assert back == value, f"Read back {back!r} of {name} differs from written {value!r}"
212
+ finally:
213
+ # Restore without validation, the original value may be outside the limits of the map
214
+ try:
215
+ restored = device.regs.write_by_name(name, original)
216
+ except Exception:
217
+ restored = None
218
+ if restored is None:
219
+ pytest.fail(f"Original value {original!r} of {name} not restored")
220
+
221
+ def test_03_read_all(self, device):
222
+ names = [n for n in device.register_names() if n not in self.exclude]
223
+ failed = [n for n in names if device.read(n) is None]
224
+ assert not failed, f"Reading {len(failed)} of {len(names)} registers failed: {', '.join(failed)}"
225
+
226
+ def test_04_unknown_register(self, device):
227
+ name = self._inject_unknown(device)
228
+ try:
229
+ value = device.read(name)
230
+ except Exception as e:
231
+ pytest.fail(f"Reading register unknown to the device raised {e!r}, set {self._unknown_hint} "
232
+ f"to an address not supported by the device")
233
+ finally:
234
+ self._remove_unknown(device, name)
235
+ assert value is None, (f"Device answered register unknown to it with {value!r}, set {self._unknown_hint} "
236
+ f"to an address not supported by the device")
237
+ self._assert_reads(device, " after unknown register")
238
+
239
+ def _assert_rejected(self, device, name: str, value: Any) -> None:
240
+ """
241
+ Write must be rejected by local validation (ValueError) with nothing sent to the device
242
+ """
243
+ stub = self._stub_client(device)
244
+ orig = self._swap_client(device, stub)
245
+ try:
246
+ with pytest.raises(ValueError):
247
+ device.write(name, value)
248
+ finally:
249
+ self._swap_client(device, orig)
250
+ assert stub.sent == [], f"Rejected write of {name} was sent to the device"
251
+
252
+ def test_06_read_only_rejected(self, device):
253
+ read_only, _ = pick_rejected_writes(device._reg_dict(), self._is_read_only)
254
+ if read_only is None:
255
+ pytest.skip("Register map has no read-only register")
256
+ self._assert_rejected(device, read_only, 0)
257
+
258
+ def test_06_out_of_range_rejected(self, device):
259
+ _, limited = pick_rejected_writes(device._reg_dict(), self._is_read_only)
260
+ if limited is None:
261
+ pytest.skip("Register map has no writable numeric register with Min/Max limits")
262
+ self._assert_rejected(device, limited, device._reg_dict()[limited]['Max'] + 1)
263
+
264
+ def test_07_reopen(self, device):
265
+ device.close()
266
+ assert device.open() is not False, "Device cannot be reopened"
267
+ self._assert_reads(device, " after reopen")
268
+
269
+ def test_08_upgrade(self, device):
270
+ if self.firmware is None:
271
+ pytest.skip("firmware is not set")
272
+ assert device.upgrade_firmware(self.firmware, self.reboot_time), f"Firmware upgrade by {self.firmware} failed"
273
+ self._assert_reads(device, " after firmware upgrade")
274
+
275
+
276
+ def _port_name(dev) -> str:
277
+ """
278
+ COM port of device for error messages
279
+ """
280
+ mb = getattr(dev, 'mb', None)
281
+ if mb is not None and getattr(mb, 's', None):
282
+ return f"port {mb.s.get('comport')}"
283
+ com = getattr(dev, 'com', None)
284
+ return f"port {getattr(com, 'comport', '?')}"
@@ -0,0 +1,67 @@
1
+ """
2
+ Communication tests of LeBin devices (LeBinDev subclasses)
3
+ """
4
+ from typing import Any, Tuple
5
+
6
+ import pytest
7
+
8
+ from .Base import DeviceTestsBase, UNKNOWN_NAME
9
+
10
+
11
+ class _RecordingCom:
12
+ """
13
+ LeBin communication object recording requests instead of sending them
14
+ """
15
+
16
+ def __init__(self):
17
+ self.ble = False
18
+ self.sent = []
19
+
20
+ def close(self):
21
+ pass
22
+
23
+ def reopen(self):
24
+ pass
25
+
26
+ def request_response(self, msg, resp_len):
27
+ self.sent.append(bytes(msg))
28
+ return b''
29
+
30
+
31
+ class LeBinDeviceTests(DeviceTestsBase):
32
+ """
33
+ Communication tests of a real LeBin device
34
+ """
35
+ #: Register ID not supported by the device (lowest 3 bits encode value length)
36
+ unknown_id: int = 0x7FFFFF02
37
+
38
+ _unknown_hint = 'unknown_id'
39
+
40
+ def _inject_unknown(self, device) -> str:
41
+ if any(r['Id'] == self.unknown_id for r in device.regs.regs):
42
+ pytest.fail(f"unknown_id {self.unknown_id:#x} exists in the register map, set unknown_id")
43
+ source = next((r for r in device.regs.regs if r['VarType'] == 'INT'), device.regs.regs[0])
44
+ reg = dict(source, Name=UNKNOWN_NAME, Id=self.unknown_id, VarType='INT', Min=0, Max=0)
45
+ device.regs.regs.append(reg)
46
+ device._regs_by_name[UNKNOWN_NAME] = reg
47
+ return UNKNOWN_NAME
48
+
49
+ def _remove_unknown(self, device, name: str) -> None:
50
+ reg = device._regs_by_name.pop(name)
51
+ if reg in device.regs.regs:
52
+ device.regs.regs.remove(reg)
53
+
54
+ def _swap_client(self, device, client) -> Any:
55
+ orig = device.com
56
+ device.open(client=client)
57
+ return orig
58
+
59
+ def _stub_client(self, device):
60
+ return _RecordingCom()
61
+
62
+ def _value_args(self, reg: dict) -> Tuple[str, int]:
63
+ # LeBin FLOAT is a 32-bit float, same as LeBinDev.write()
64
+ return 'FLOAT32' if reg['VarType'] == 'FLOAT' else reg['VarType'], 8 * (1 << (reg['Id'] & 7))
65
+
66
+ def _is_read_only(self, reg: dict) -> bool:
67
+ return str(reg.get('Access', '')).startswith('RO')
@@ -0,0 +1,80 @@
1
+ """
2
+ Communication tests of Modbus devices (ModbusDev subclasses)
3
+ """
4
+ from typing import Any, Tuple
5
+
6
+ from ..VisualModbus.MbClient import MbClient
7
+ from .Base import DeviceTestsBase, UNKNOWN_NAME
8
+
9
+
10
+ class _RecordingClient(MbClient):
11
+ """
12
+ Modbus client recording requests instead of sending them
13
+ """
14
+
15
+ def __init__(self):
16
+ super().__init__()
17
+ self.sent = []
18
+
19
+ def open(self, settings=None, connectionless=False):
20
+ return True
21
+
22
+ def close(self):
23
+ pass
24
+
25
+ def read(self, request):
26
+ self.sent.append(request)
27
+ return None
28
+
29
+ def write_hold(self, request):
30
+ self.sent.append(request)
31
+ return None
32
+
33
+
34
+ class ModbusDeviceTests(DeviceTestsBase):
35
+ """
36
+ Communication tests of a real Modbus device
37
+ """
38
+ #: Register address not supported by the device
39
+ unknown_address: int = 65000
40
+ #: Slave address with no device on the bus
41
+ wrong_slave: int = 247
42
+
43
+ _unknown_hint = 'unknown_address'
44
+
45
+ def _inject_unknown(self, device) -> str:
46
+ source = (device.regs.input or device.regs.hold)[0]
47
+ reg = dict(source, Name=UNKNOWN_NAME, Address=[self.unknown_address], Format='INT', Min=0, Max=0)
48
+ (device.regs.input if reg['Type'] != 'HOLD' else device.regs.hold).append(reg)
49
+ device._regs_by_name[UNKNOWN_NAME] = reg
50
+ return UNKNOWN_NAME
51
+
52
+ def _remove_unknown(self, device, name: str) -> None:
53
+ reg = device._regs_by_name.pop(name)
54
+ for regs in (device.regs.input, device.regs.hold):
55
+ if reg in regs:
56
+ regs.remove(reg)
57
+
58
+ def _swap_client(self, device, client) -> Any:
59
+ orig = device.mb
60
+ device.open(client=client)
61
+ return orig
62
+
63
+ def _stub_client(self, device):
64
+ return _RecordingClient()
65
+
66
+ def _value_args(self, reg: dict) -> Tuple[str, int]:
67
+ return reg['Format'], 16 * len(reg['Address'])
68
+
69
+ def _is_read_only(self, reg: dict) -> bool:
70
+ return reg['Type'] != 'HOLD' or str(reg.get('Access', '')).startswith('RO')
71
+
72
+ def test_05_wrong_slave(self, device):
73
+ orig = device.regs.slave
74
+ device.regs.slave = self.wrong_slave
75
+ try:
76
+ value = device.read(self._probe_name(device))
77
+ finally:
78
+ device.regs.slave = orig
79
+ assert value is None, f"A device answered on wrong slave address {self.wrong_slave}, set wrong_slave"
80
+ self._assert_reads(device, " after wrong slave address")
@@ -0,0 +1,20 @@
1
+ """
2
+ Pytest plugin of lecore (entry point pytest11), registers marker of device tests and runs firmware upgrade last
3
+ """
4
+ #: Test run as the last one of its class, the device reboots into new firmware
5
+ LAST_TEST = 'test_08_upgrade'
6
+
7
+
8
+ def pytest_configure(config):
9
+ config.addinivalue_line("markers", "hardware: test needs a real device (deselect by -m 'not hardware')")
10
+
11
+
12
+ def pytest_collection_modifyitems(items):
13
+ # Inherited tests are collected before tests of subclasses, move the upgrade after the last test of its class
14
+ for item in [i for i in items if i.name == LAST_TEST]:
15
+ cls = getattr(item, 'cls', None)
16
+ if cls is None:
17
+ continue
18
+ last = max(n for n, i in enumerate(items) if getattr(i, 'cls', None) is cls)
19
+ items.remove(item)
20
+ items.insert(last, item)
@@ -0,0 +1,15 @@
1
+ """
2
+ Reusable pytest suite testing communication of real devices, see doc/new-device.md
3
+ """
4
+ __all__ = ['ModbusDeviceTests', 'LeBinDeviceTests']
5
+
6
+
7
+ def __getattr__(name):
8
+ # Lazy import, the pytest plugin of this package must not load the Modbus/LeBin stack into every pytest session
9
+ if name == 'ModbusDeviceTests':
10
+ from .ModbusTests import ModbusDeviceTests
11
+ return ModbusDeviceTests
12
+ if name == 'LeBinDeviceTests':
13
+ from .LeBinTests import LeBinDeviceTests
14
+ return LeBinDeviceTests
15
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")