lecore 0.2.2__tar.gz → 0.2.5__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.
- {lecore-0.2.2/src/lecore.egg-info → lecore-0.2.5}/PKG-INFO +2 -2
- {lecore-0.2.2 → lecore-0.2.5}/pyproject.toml +5 -2
- lecore-0.2.5/src/lecore/DeviceTests/Base.py +284 -0
- lecore-0.2.5/src/lecore/DeviceTests/LeBinTests.py +67 -0
- lecore-0.2.5/src/lecore/DeviceTests/ModbusTests.py +80 -0
- lecore-0.2.5/src/lecore/DeviceTests/Plugin.py +20 -0
- lecore-0.2.5/src/lecore/DeviceTests/__init__.py +15 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/LeBinDev.py +271 -269
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/LeBin/RegisterMap.py +361 -361
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/LeBin/SerialCom.py +8 -5
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/LeBin/UpgradeFirmware.py +97 -83
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/LeBin/VisualLeBin.py +77 -20
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/QUICKSTART.md +136 -98
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Scaffold.py +151 -7
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/AppLogging.py +139 -136
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/HelpAbout.py +1 -7
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/MbClient.py +37 -11
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/RegMap.py +420 -420
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/VisualMbApp.py +255 -238
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/__init__.py +2 -2
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/__main__.py +75 -67
- lecore-0.2.5/src/lecore/_version.py +7 -0
- {lecore-0.2.2 → lecore-0.2.5/src/lecore.egg-info}/PKG-INFO +2 -2
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore.egg-info/SOURCES.txt +6 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore.egg-info/entry_points.txt +3 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore.egg-info/requires.txt +1 -1
- {lecore-0.2.2 → lecore-0.2.5}/LICENSE +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/MANIFEST.in +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/README.md +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/setup.cfg +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/DeviceBase.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/ModbusDev.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/PhaseDet/ComSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/PhaseDet/PhaseDet.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/PhaseDet/PhaseDetRegs.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/PhaseDet/PhaseDet_Modbus.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/PhaseDet/UpgradeSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/PhaseDet/VisualSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/PhaseDet/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/ComSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/Rftb.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/RftbLeBin.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/UpgradeSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/VisualSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/rftbRegs.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/rftb_Modbus.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Rftb/rftb_registers.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/RtdEmul/ComSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/RtdEmul/RtdEmul.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/RtdEmul/RtdEmulRegs.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/RtdEmul/RtdEmul_Modbus.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/RtdEmul/UpgradeSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/RtdEmul/VisualSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/RtdEmul/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/Validation.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/VmsRtd/ComSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/VmsRtd/UpgradeSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/VmsRtd/VMS_RTDRegs.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/VmsRtd/VMS_RTD_Modbus.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/VmsRtd/VisualSettings.json +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/VmsRtd/VmsRtd.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/VmsRtd/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Devices/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Doc/ModbusTable.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Doc/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Fg320/Fg320.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Fg320/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/LeBin/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/Looger.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/TestFrame/CoreTest.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/TestFrame/ModbusUnitTest.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/TestFrame/RtuClient.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/TestFrame/Runner.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/TestFrame/TestUtils.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/TestFrame/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/Crc32.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/MbUpgrade.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/ReadWrite.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/TextMbApp.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/TextMbAppCss.tcss +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/VmSettings.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/VisualModbus/__init__.py +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore/py.typed +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/src/lecore.egg-info/dependency_links.txt +0 -0
- {lecore-0.2.2 → lecore-0.2.5}/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
|
+
Version: 0.2.5
|
|
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
|
|
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"
|
|
@@ -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.
|
|
8
|
+
version = "0.2.5"
|
|
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
|
|
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}")
|