modbus-sim 0.1.0__py3-none-any.whl
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.
- modbus_sim/__init__.py +11 -0
- modbus_sim/__main__.py +5 -0
- modbus_sim/bench.py +218 -0
- modbus_sim/cli.py +373 -0
- modbus_sim/client.py +257 -0
- modbus_sim/control.py +207 -0
- modbus_sim/datatypes.py +123 -0
- modbus_sim/device.py +413 -0
- modbus_sim/faults.py +197 -0
- modbus_sim/frame.py +113 -0
- modbus_sim/models.py +431 -0
- modbus_sim/profile.py +346 -0
- modbus_sim/profiles/energy-meter.yaml +57 -0
- modbus_sim/profiles/temperature-controller.yaml +39 -0
- modbus_sim/profiles/vfd.yaml +45 -0
- modbus_sim/pytest_plugin.py +95 -0
- modbus_sim/server.py +368 -0
- modbus_sim/signals.py +31 -0
- modbus_sim-0.1.0.dist-info/METADATA +337 -0
- modbus_sim-0.1.0.dist-info/RECORD +24 -0
- modbus_sim-0.1.0.dist-info/WHEEL +5 -0
- modbus_sim-0.1.0.dist-info/entry_points.txt +5 -0
- modbus_sim-0.1.0.dist-info/licenses/LICENSE +21 -0
- modbus_sim-0.1.0.dist-info/top_level.txt +1 -0
modbus_sim/client.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""A small Modbus master for trying the simulator out (and for its own CLI).
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from modbus_sim.client import ModbusClient
|
|
6
|
+
with ModbusClient("127.0.0.1", 5020) as c:
|
|
7
|
+
c.read_holding_registers(100, 2, unit=3) # -> [status, frequency]
|
|
8
|
+
c.write_register(0, 1, unit=3)
|
|
9
|
+
|
|
10
|
+
``ModbusClient.serial("/dev/pts/7")`` talks RTU on a serial line or pty instead, and
|
|
11
|
+
``framing="rtu"`` speaks RTU over TCP. It is deliberately minimal: one request at a
|
|
12
|
+
time, no retries. For real projects use pymodbus or similar.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import select
|
|
18
|
+
import socket
|
|
19
|
+
import struct
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from . import frame
|
|
24
|
+
|
|
25
|
+
EXCEPTIONS = {
|
|
26
|
+
1: "illegal function", 2: "illegal data address", 3: "illegal data value",
|
|
27
|
+
4: "server device failure", 5: "acknowledge", 6: "server device busy",
|
|
28
|
+
8: "memory parity error", 10: "gateway path unavailable",
|
|
29
|
+
11: "gateway target device failed to respond",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ModbusError(Exception):
|
|
34
|
+
"""The device answered with an exception code."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, function: int, code: int):
|
|
37
|
+
self.function, self.code = function, code
|
|
38
|
+
super().__init__(f"function {function:#04x}: exception {code:#04x} "
|
|
39
|
+
f"({EXCEPTIONS.get(code, 'unknown')})")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProtocolError(Exception):
|
|
43
|
+
"""The answer did not fit the request (wrong transaction, unit, CRC, length)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ModbusClient:
|
|
47
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 5020, timeout: float = 2.0,
|
|
48
|
+
framing: str = "tcp"):
|
|
49
|
+
if framing not in ("tcp", "rtu"):
|
|
50
|
+
raise ValueError("framing is tcp or rtu")
|
|
51
|
+
self.host, self.port, self.timeout, self.framing = host, port, timeout, framing
|
|
52
|
+
self._sock: socket.socket | None = None
|
|
53
|
+
self._fd: int | None = None
|
|
54
|
+
self._serial = None
|
|
55
|
+
self._transaction = 0
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def serial(cls, path: str, baudrate: int = 19200, parity: str = "E",
|
|
59
|
+
timeout: float = 2.0) -> "ModbusClient":
|
|
60
|
+
client = cls(timeout=timeout, framing="rtu")
|
|
61
|
+
client.host, client.port = path, None
|
|
62
|
+
client._serial_args = (baudrate, parity)
|
|
63
|
+
return client
|
|
64
|
+
|
|
65
|
+
# --- connection ---
|
|
66
|
+
|
|
67
|
+
def connect(self) -> "ModbusClient":
|
|
68
|
+
if self.port is None:
|
|
69
|
+
self._open_serial(self.host, *self._serial_args)
|
|
70
|
+
else:
|
|
71
|
+
self._sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
|
72
|
+
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def _open_serial(self, path: str, baudrate: int, parity: str) -> None:
|
|
76
|
+
if is_pty(path):
|
|
77
|
+
import tty
|
|
78
|
+
self._fd = os.open(path, os.O_RDWR | os.O_NOCTTY)
|
|
79
|
+
tty.setraw(self._fd)
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
import serial # type: ignore[import-untyped]
|
|
83
|
+
except ImportError:
|
|
84
|
+
raise OSError("a serial port needs pyserial: pip install 'modbus-sim[serial]'"
|
|
85
|
+
) from None
|
|
86
|
+
self._serial = serial.Serial(path, baudrate, parity=parity, timeout=self.timeout)
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
if self._sock is not None:
|
|
90
|
+
self._sock.close()
|
|
91
|
+
self._sock = None
|
|
92
|
+
if self._fd is not None:
|
|
93
|
+
os.close(self._fd)
|
|
94
|
+
self._fd = None
|
|
95
|
+
if self._serial is not None:
|
|
96
|
+
self._serial.close()
|
|
97
|
+
self._serial = None
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> "ModbusClient":
|
|
100
|
+
if self._sock is None and self._fd is None and self._serial is None:
|
|
101
|
+
self.connect()
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def __exit__(self, *exc) -> None:
|
|
105
|
+
self.close()
|
|
106
|
+
|
|
107
|
+
# --- raw I/O ---
|
|
108
|
+
|
|
109
|
+
def _send(self, data: bytes) -> None:
|
|
110
|
+
if self._sock is not None:
|
|
111
|
+
self._sock.sendall(data)
|
|
112
|
+
elif self._fd is not None:
|
|
113
|
+
os.write(self._fd, data)
|
|
114
|
+
elif self._serial is not None:
|
|
115
|
+
self._serial.write(data)
|
|
116
|
+
else:
|
|
117
|
+
raise OSError("not connected")
|
|
118
|
+
|
|
119
|
+
def _recv(self, n: int, deadline: float) -> bytes:
|
|
120
|
+
data = b""
|
|
121
|
+
while len(data) < n:
|
|
122
|
+
left = deadline - time.monotonic()
|
|
123
|
+
if left <= 0:
|
|
124
|
+
raise TimeoutError("no (complete) answer")
|
|
125
|
+
if self._sock is not None:
|
|
126
|
+
self._sock.settimeout(left)
|
|
127
|
+
try:
|
|
128
|
+
chunk = self._sock.recv(n - len(data))
|
|
129
|
+
except socket.timeout:
|
|
130
|
+
raise TimeoutError("no (complete) answer") from None
|
|
131
|
+
if not chunk:
|
|
132
|
+
raise ConnectionError("connection closed by the device")
|
|
133
|
+
elif self._fd is not None:
|
|
134
|
+
ready, _, _ = select.select([self._fd], [], [], left)
|
|
135
|
+
chunk = os.read(self._fd, n - len(data)) if ready else b""
|
|
136
|
+
else:
|
|
137
|
+
self._serial.timeout = left
|
|
138
|
+
chunk = self._serial.read(n - len(data))
|
|
139
|
+
data += chunk
|
|
140
|
+
return data
|
|
141
|
+
|
|
142
|
+
def request(self, pdu: bytes, unit: int = 1) -> bytes:
|
|
143
|
+
"""Send a request PDU, return the response PDU; raises ModbusError on an exception."""
|
|
144
|
+
deadline = time.monotonic() + self.timeout
|
|
145
|
+
if self.framing == "tcp":
|
|
146
|
+
self._transaction = (self._transaction + 1) & 0xFFFF
|
|
147
|
+
self._send(frame.mbap(self._transaction, unit, pdu))
|
|
148
|
+
transaction, protocol, length, answered = frame.parse_mbap(
|
|
149
|
+
self._recv(frame.MBAP_LEN, deadline))
|
|
150
|
+
body = self._recv(length - 1, deadline)
|
|
151
|
+
if transaction != self._transaction or protocol != 0:
|
|
152
|
+
raise ProtocolError(f"answer has transaction {transaction}, "
|
|
153
|
+
f"expected {self._transaction}")
|
|
154
|
+
if answered != unit:
|
|
155
|
+
raise ProtocolError(f"answer from unit {answered}, expected {unit}")
|
|
156
|
+
else:
|
|
157
|
+
self._discard_input()
|
|
158
|
+
self._send(frame.add_crc(bytes([unit]) + pdu))
|
|
159
|
+
if unit == 0:
|
|
160
|
+
return b"" # broadcast: nobody answers
|
|
161
|
+
packet = self._recv(3, deadline)
|
|
162
|
+
total = frame.response_length(packet)
|
|
163
|
+
if total is None:
|
|
164
|
+
raise ProtocolError(f"cannot frame the answer {packet.hex()}")
|
|
165
|
+
packet += self._recv(total - 3, deadline)
|
|
166
|
+
if not frame.check_crc(packet):
|
|
167
|
+
raise ProtocolError("CRC error in the answer")
|
|
168
|
+
if packet[0] != unit:
|
|
169
|
+
raise ProtocolError(f"answer from unit {packet[0]}, expected {unit}")
|
|
170
|
+
body = packet[1:-2]
|
|
171
|
+
if body and body[0] == (pdu[0] | 0x80):
|
|
172
|
+
raise ModbusError(pdu[0], body[1] if len(body) > 1 else 0)
|
|
173
|
+
if not body or body[0] != pdu[0]:
|
|
174
|
+
raise ProtocolError(f"unexpected answer {body.hex()}")
|
|
175
|
+
return body
|
|
176
|
+
|
|
177
|
+
def _discard_input(self) -> None:
|
|
178
|
+
"""Drop left-overs of an earlier, broken answer before a new RTU request."""
|
|
179
|
+
if self._sock is not None:
|
|
180
|
+
self._sock.setblocking(False)
|
|
181
|
+
try:
|
|
182
|
+
while self._sock.recv(512):
|
|
183
|
+
pass
|
|
184
|
+
except (BlockingIOError, OSError):
|
|
185
|
+
pass
|
|
186
|
+
finally:
|
|
187
|
+
self._sock.setblocking(True)
|
|
188
|
+
elif self._fd is not None:
|
|
189
|
+
while select.select([self._fd], [], [], 0)[0]:
|
|
190
|
+
if not os.read(self._fd, 512):
|
|
191
|
+
break
|
|
192
|
+
elif self._serial is not None:
|
|
193
|
+
self._serial.reset_input_buffer()
|
|
194
|
+
|
|
195
|
+
# --- function codes ---
|
|
196
|
+
|
|
197
|
+
def _read_bits(self, function: int, address: int, count: int, unit: int) -> list[bool]:
|
|
198
|
+
body = self.request(struct.pack(">BHH", function, address, count), unit)
|
|
199
|
+
data = body[2:]
|
|
200
|
+
return [bool(data[i // 8] >> (i % 8) & 1) for i in range(count)]
|
|
201
|
+
|
|
202
|
+
def read_coils(self, address: int, count: int = 1, unit: int = 1) -> list[bool]:
|
|
203
|
+
return self._read_bits(1, address, count, unit)
|
|
204
|
+
|
|
205
|
+
def read_discrete_inputs(self, address: int, count: int = 1, unit: int = 1) -> list[bool]:
|
|
206
|
+
return self._read_bits(2, address, count, unit)
|
|
207
|
+
|
|
208
|
+
def _read_regs(self, function: int, address: int, count: int, unit: int) -> list[int]:
|
|
209
|
+
body = self.request(struct.pack(">BHH", function, address, count), unit)
|
|
210
|
+
if body[1] != 2 * count or len(body) != 2 + 2 * count:
|
|
211
|
+
raise ProtocolError(f"expected {count} registers, got {body[1] // 2}")
|
|
212
|
+
return list(struct.unpack(f">{count}H", body[2:]))
|
|
213
|
+
|
|
214
|
+
def read_holding_registers(self, address: int, count: int = 1, unit: int = 1) -> list[int]:
|
|
215
|
+
return self._read_regs(3, address, count, unit)
|
|
216
|
+
|
|
217
|
+
def read_input_registers(self, address: int, count: int = 1, unit: int = 1) -> list[int]:
|
|
218
|
+
return self._read_regs(4, address, count, unit)
|
|
219
|
+
|
|
220
|
+
def write_coil(self, address: int, value: bool, unit: int = 1) -> None:
|
|
221
|
+
self.request(struct.pack(">BHH", 5, address, 0xFF00 if value else 0), unit)
|
|
222
|
+
|
|
223
|
+
def write_register(self, address: int, value: int, unit: int = 1) -> None:
|
|
224
|
+
self.request(struct.pack(">BHH", 6, address, value & 0xFFFF), unit)
|
|
225
|
+
|
|
226
|
+
def write_coils(self, address: int, values: list[bool], unit: int = 1) -> None:
|
|
227
|
+
data = bytearray((len(values) + 7) // 8)
|
|
228
|
+
for i, value in enumerate(values):
|
|
229
|
+
if value:
|
|
230
|
+
data[i // 8] |= 1 << (i % 8)
|
|
231
|
+
self.request(struct.pack(">BHHB", 15, address, len(values), len(data)) + bytes(data),
|
|
232
|
+
unit)
|
|
233
|
+
|
|
234
|
+
def write_registers(self, address: int, values: list[int], unit: int = 1) -> None:
|
|
235
|
+
self.request(struct.pack(f">BHHB{len(values)}H", 16, address, len(values),
|
|
236
|
+
2 * len(values), *[v & 0xFFFF for v in values]), unit)
|
|
237
|
+
|
|
238
|
+
def read_device_identification(self, unit: int = 1, code: int = 2) -> dict[int, str]:
|
|
239
|
+
"""Objects 0.. (vendor, product code, revision, ...) as {id: text}."""
|
|
240
|
+
result: dict[int, str] = {}
|
|
241
|
+
object_id = 0
|
|
242
|
+
while True:
|
|
243
|
+
body = self.request(bytes([0x2B, 0x0E, code, object_id]), unit)
|
|
244
|
+
more, next_id, count = body[4], body[5], body[6]
|
|
245
|
+
offset = 7
|
|
246
|
+
for _ in range(count):
|
|
247
|
+
oid, length = body[offset], body[offset + 1]
|
|
248
|
+
result[oid] = body[offset + 2:offset + 2 + length].decode("latin-1")
|
|
249
|
+
offset += 2 + length
|
|
250
|
+
if more != 0xFF:
|
|
251
|
+
return result
|
|
252
|
+
object_id = next_id
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def is_pty(path: str) -> bool:
|
|
256
|
+
return sys.platform != "win32" and (path.startswith("/dev/pts/")
|
|
257
|
+
or path.startswith("/dev/ttys"))
|
modbus_sim/control.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Control channel of a running bench: inject faults, set and read values from outside.
|
|
2
|
+
|
|
3
|
+
One JSON object per line in, one per line out. ``device`` is a device name or unit ID::
|
|
4
|
+
|
|
5
|
+
{"device": "vfd", "inject": "trip", "params": {"code": 7}} -> {"ok": true}
|
|
6
|
+
{"device": "vfd", "clear": true} (or "clear": "trip") -> {"ok": true}
|
|
7
|
+
{"device": 3, "set": {"speed_reference": 25.0}} (null = back to model/source)
|
|
8
|
+
{"device": "vfd", "get": ["output_frequency"]} (or "get": true for all points)
|
|
9
|
+
-> {"ok": true, "values": {"output_frequency": 12.5}}
|
|
10
|
+
{"list": true} -> {"ok": true, "devices": {"vfd": {"unit": 3, "profile": "vfd",
|
|
11
|
+
"faults": {...}, "requests": 12}}}
|
|
12
|
+
|
|
13
|
+
Anything invalid gets ``{"ok": false, "error": "..."}``; the connection stays open.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import socket
|
|
21
|
+
import socketserver
|
|
22
|
+
import threading
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .bench import Bench
|
|
27
|
+
from .device import Device, Point
|
|
28
|
+
|
|
29
|
+
CONTROL_PORT = 5021
|
|
30
|
+
_MAX_LINE = 65536
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Handler(socketserver.StreamRequestHandler):
|
|
34
|
+
def setup(self) -> None:
|
|
35
|
+
super().setup()
|
|
36
|
+
self.server.connections.add(self.request)
|
|
37
|
+
|
|
38
|
+
def finish(self) -> None:
|
|
39
|
+
self.server.connections.discard(self.request)
|
|
40
|
+
super().finish()
|
|
41
|
+
|
|
42
|
+
def handle(self) -> None:
|
|
43
|
+
try:
|
|
44
|
+
while True:
|
|
45
|
+
raw = self.rfile.readline(_MAX_LINE)
|
|
46
|
+
if not raw:
|
|
47
|
+
return
|
|
48
|
+
if len(raw) >= _MAX_LINE and not raw.endswith(b"\n"):
|
|
49
|
+
self.wfile.write(json.dumps({"ok": False, "error": "line too long"})
|
|
50
|
+
.encode() + b"\n")
|
|
51
|
+
self.wfile.flush()
|
|
52
|
+
self.request.shutdown(socket.SHUT_WR)
|
|
53
|
+
return
|
|
54
|
+
if not raw.strip():
|
|
55
|
+
continue
|
|
56
|
+
answer = self.server.control.handle(raw)
|
|
57
|
+
self.wfile.write(json.dumps(answer).encode() + b"\n")
|
|
58
|
+
except OSError:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Server(socketserver.ThreadingTCPServer):
|
|
63
|
+
daemon_threads = True
|
|
64
|
+
allow_reuse_address = os.name != "nt"
|
|
65
|
+
|
|
66
|
+
def __init__(self, address, handler):
|
|
67
|
+
self.address_family = socket.getaddrinfo(address[0], address[1],
|
|
68
|
+
type=socket.SOCK_STREAM)[0][0]
|
|
69
|
+
self.connections: set[socket.socket] = set()
|
|
70
|
+
super().__init__(address, handler)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ControlServer:
|
|
74
|
+
def __init__(self, bench: "Bench", host: str = "127.0.0.1", port: int = CONTROL_PORT):
|
|
75
|
+
self.bench, self.host, self.port = bench, host, port
|
|
76
|
+
self._server: _Server | None = None
|
|
77
|
+
self._thread: threading.Thread | None = None
|
|
78
|
+
|
|
79
|
+
def start(self) -> int:
|
|
80
|
+
self._server = _Server((self.host, self.port), _Handler)
|
|
81
|
+
self._server.control = self
|
|
82
|
+
self.port = self._server.server_address[1]
|
|
83
|
+
self._thread = threading.Thread(target=self._server.serve_forever,
|
|
84
|
+
kwargs={"poll_interval": 0.05}, daemon=True,
|
|
85
|
+
name="modbus-sim-control")
|
|
86
|
+
self._thread.start()
|
|
87
|
+
return self.port
|
|
88
|
+
|
|
89
|
+
def stop(self) -> None:
|
|
90
|
+
if self._server is None:
|
|
91
|
+
return
|
|
92
|
+
self._server.shutdown()
|
|
93
|
+
self._server.server_close()
|
|
94
|
+
for connection in list(self._server.connections):
|
|
95
|
+
try:
|
|
96
|
+
connection.shutdown(socket.SHUT_RDWR)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
self._thread.join(timeout=5)
|
|
100
|
+
self._server = self._thread = None
|
|
101
|
+
|
|
102
|
+
def handle(self, raw: bytes | str) -> dict[str, Any]:
|
|
103
|
+
try:
|
|
104
|
+
request = json.loads(raw)
|
|
105
|
+
if not isinstance(request, dict):
|
|
106
|
+
raise ValueError("a request is a JSON object")
|
|
107
|
+
return self._handle(request)
|
|
108
|
+
except ValueError as error: # includes JSONDecodeError and FaultError
|
|
109
|
+
return {"ok": False, "error": str(error)}
|
|
110
|
+
except Exception as error: # never drop the connection with a traceback
|
|
111
|
+
return {"ok": False, "error": f"{type(error).__name__}: {error}"}
|
|
112
|
+
|
|
113
|
+
def _handle(self, request: dict) -> dict[str, Any]:
|
|
114
|
+
if request.get("list"):
|
|
115
|
+
return {"ok": True, "devices": {name: self._describe(dev)
|
|
116
|
+
for name, dev in self.bench.devices.items()}}
|
|
117
|
+
device = self.bench.find(request.get("device"))
|
|
118
|
+
if request.get("clear"):
|
|
119
|
+
kind = request["clear"]
|
|
120
|
+
if kind is True:
|
|
121
|
+
device.faults.clear()
|
|
122
|
+
elif isinstance(kind, str):
|
|
123
|
+
device.faults.clear(kind)
|
|
124
|
+
else:
|
|
125
|
+
raise ValueError("'clear' must be true or a fault name")
|
|
126
|
+
elif "inject" in request:
|
|
127
|
+
kind, params = request["inject"], request.get("params") or {}
|
|
128
|
+
if not isinstance(kind, str):
|
|
129
|
+
raise ValueError("'inject' must be a fault name")
|
|
130
|
+
if not isinstance(params, dict):
|
|
131
|
+
raise ValueError("'params' must be an object")
|
|
132
|
+
device.faults.inject(kind, **params)
|
|
133
|
+
elif "set" in request:
|
|
134
|
+
self._set(device, request["set"])
|
|
135
|
+
elif "get" in request:
|
|
136
|
+
return {"ok": True, "values": self._get(device, request["get"])}
|
|
137
|
+
else:
|
|
138
|
+
raise ValueError("a request needs 'list', 'clear', 'inject', 'set' or 'get'")
|
|
139
|
+
return {"ok": True}
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _point(device: "Device", name: Any) -> "Point":
|
|
143
|
+
point = device.points.get(name) if isinstance(name, str) else None
|
|
144
|
+
if point is None:
|
|
145
|
+
raise ValueError(f"{device.name} has no point {name!r} "
|
|
146
|
+
f"(points: {', '.join(device.points)})")
|
|
147
|
+
return point
|
|
148
|
+
|
|
149
|
+
def _set(self, device: "Device", values: Any) -> None:
|
|
150
|
+
if not isinstance(values, dict) or not values:
|
|
151
|
+
raise ValueError("'set' must be a non-empty object like {\"setpoint\": 80}")
|
|
152
|
+
parsed = []
|
|
153
|
+
for name, value in values.items():
|
|
154
|
+
point = self._point(device, name)
|
|
155
|
+
kind = point.spec.type
|
|
156
|
+
if value is None:
|
|
157
|
+
pass
|
|
158
|
+
elif kind == "string":
|
|
159
|
+
if not isinstance(value, str):
|
|
160
|
+
raise ValueError(f"{name}: expected a string")
|
|
161
|
+
elif kind == "bool":
|
|
162
|
+
if not isinstance(value, bool):
|
|
163
|
+
raise ValueError(f"{name}: expected true or false")
|
|
164
|
+
elif isinstance(value, bool) or not isinstance(value, (int, float)) \
|
|
165
|
+
or not math.isfinite(value):
|
|
166
|
+
raise ValueError(f"{name}: expected a finite number or null")
|
|
167
|
+
parsed.append((point, value))
|
|
168
|
+
with device.lock: # all or nothing, atomic against the Modbus side
|
|
169
|
+
for point, value in parsed:
|
|
170
|
+
if point.spec.model or point.signal is not None:
|
|
171
|
+
point.override = value
|
|
172
|
+
elif value is not None:
|
|
173
|
+
point.stored = value
|
|
174
|
+
if device.frozen is not None:
|
|
175
|
+
device.frozen[point.name] = point.live()
|
|
176
|
+
|
|
177
|
+
def _get(self, device: "Device", names: Any) -> dict[str, Any]:
|
|
178
|
+
if names is True:
|
|
179
|
+
names = list(device.points)
|
|
180
|
+
if not isinstance(names, list):
|
|
181
|
+
raise ValueError("'get' must be true or a list of point names")
|
|
182
|
+
with device.lock:
|
|
183
|
+
result = {}
|
|
184
|
+
for name in names:
|
|
185
|
+
value = self._point(device, name).value()
|
|
186
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
187
|
+
value = str(value) # JSON has no inf/nan
|
|
188
|
+
result[name] = value
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _describe(device: "Device") -> dict[str, Any]:
|
|
193
|
+
return {"unit": device.unit, "profile": device.profile.name,
|
|
194
|
+
"faults": device.faults.snapshot(), "requests": device.requests}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def send_control(request: dict, host: str = "127.0.0.1", port: int = CONTROL_PORT,
|
|
198
|
+
timeout: float = 2.0) -> dict:
|
|
199
|
+
"""Send one request to a bench's control port; raises OSError if it cannot be reached."""
|
|
200
|
+
with socket.create_connection((host, port), timeout=timeout) as sock, \
|
|
201
|
+
sock.makefile("rwb") as stream:
|
|
202
|
+
stream.write(json.dumps(request).encode() + b"\n")
|
|
203
|
+
stream.flush()
|
|
204
|
+
line = stream.readline()
|
|
205
|
+
if not line:
|
|
206
|
+
raise OSError("control port closed the connection")
|
|
207
|
+
return json.loads(line)
|
modbus_sim/datatypes.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Values <-> 16-bit registers, with the word and byte orders found in real devices.
|
|
2
|
+
|
|
3
|
+
Word order names follow the usual convention for a 32-bit value whose big-endian
|
|
4
|
+
bytes are A B C D:
|
|
5
|
+
|
|
6
|
+
===== =============== ================================================
|
|
7
|
+
order registers seen in
|
|
8
|
+
===== =============== ================================================
|
|
9
|
+
ABCD [AB, CD] the Modbus default (big endian), most PLCs
|
|
10
|
+
CDAB [CD, AB] word-swapped: many energy meters, Schneider, Eastron
|
|
11
|
+
BADC [BA, DC] byte-swapped
|
|
12
|
+
DCBA [DC, BA] little endian
|
|
13
|
+
===== =============== ================================================
|
|
14
|
+
|
|
15
|
+
64-bit values extend the same rule (word order and byte order). Only types of
|
|
16
|
+
32 bits or more are affected; 16-bit types are always one plain register.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
import struct
|
|
22
|
+
|
|
23
|
+
WORD_ORDERS = ("ABCD", "CDAB", "BADC", "DCBA")
|
|
24
|
+
|
|
25
|
+
# type -> (struct format, registers)
|
|
26
|
+
_NUMERIC = {
|
|
27
|
+
"uint16": (">H", 1), "int16": (">h", 1),
|
|
28
|
+
"uint32": (">I", 2), "int32": (">i", 2), "float32": (">f", 2),
|
|
29
|
+
"uint64": (">Q", 4), "int64": (">q", 4), "float64": (">d", 4),
|
|
30
|
+
}
|
|
31
|
+
_RANGES = {
|
|
32
|
+
"uint16": (0, 0xFFFF), "int16": (-0x8000, 0x7FFF),
|
|
33
|
+
"uint32": (0, 0xFFFFFFFF), "int32": (-0x80000000, 0x7FFFFFFF),
|
|
34
|
+
"uint64": (0, 0xFFFFFFFFFFFFFFFF), "int64": (-(1 << 63), (1 << 63) - 1),
|
|
35
|
+
}
|
|
36
|
+
REGISTER_TYPES = (*_NUMERIC, "string")
|
|
37
|
+
BIT_TYPES = ("bool",)
|
|
38
|
+
TYPES = REGISTER_TYPES + BIT_TYPES
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_integer(kind: str) -> bool:
|
|
42
|
+
return kind in _RANGES
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_float(kind: str) -> bool:
|
|
46
|
+
return kind in ("float32", "float64")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def registers(kind: str, length: int | None = None) -> int:
|
|
50
|
+
"""Number of registers a value of `kind` occupies (`length` for strings)."""
|
|
51
|
+
if kind == "string":
|
|
52
|
+
if not length or length < 1:
|
|
53
|
+
raise ValueError("a string needs a length in registers")
|
|
54
|
+
return length
|
|
55
|
+
if kind in _NUMERIC:
|
|
56
|
+
return _NUMERIC[kind][1]
|
|
57
|
+
raise ValueError(f"{kind!r} is not a register type")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _order(raw: bytes, order: str) -> bytes:
|
|
61
|
+
if len(raw) <= 2 or order == "ABCD":
|
|
62
|
+
return raw
|
|
63
|
+
words = [raw[i:i + 2] for i in range(0, len(raw), 2)]
|
|
64
|
+
if order in ("CDAB", "DCBA"):
|
|
65
|
+
words.reverse()
|
|
66
|
+
if order in ("BADC", "DCBA"):
|
|
67
|
+
words = [w[::-1] for w in words]
|
|
68
|
+
return b"".join(words)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _to_words(raw: bytes) -> list[int]:
|
|
72
|
+
return list(struct.unpack(f">{len(raw) // 2}H", raw))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _from_words(words: list[int]) -> bytes:
|
|
76
|
+
return struct.pack(f">{len(words)}H", *words)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def saturate(kind: str, raw: float) -> int:
|
|
80
|
+
"""Round and clamp to the integer type's range (devices saturate rather than wrap)."""
|
|
81
|
+
low, high = _RANGES[kind]
|
|
82
|
+
if math.isnan(raw):
|
|
83
|
+
return 0
|
|
84
|
+
if math.isinf(raw):
|
|
85
|
+
return high if raw > 0 else low
|
|
86
|
+
return int(min(max(round(raw), low), high))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def encode(value, kind: str, order: str = "ABCD", scale: float = 1.0,
|
|
90
|
+
length: int | None = None) -> list[int]:
|
|
91
|
+
"""Engineering value -> registers. Integers are value/scale, rounded and clamped."""
|
|
92
|
+
if kind == "string":
|
|
93
|
+
data = str(value).encode("latin-1", "replace")[:2 * length]
|
|
94
|
+
return _to_words(data.ljust(2 * length, b"\0"))
|
|
95
|
+
fmt, _ = _NUMERIC[kind]
|
|
96
|
+
if is_float(kind):
|
|
97
|
+
raw = float(value) / scale
|
|
98
|
+
if kind == "float32" and math.isfinite(raw) and abs(raw) > 3.4028234663852886e38:
|
|
99
|
+
raw = math.copysign(math.inf, raw)
|
|
100
|
+
data = struct.pack(fmt, raw)
|
|
101
|
+
else:
|
|
102
|
+
data = struct.pack(fmt, saturate(kind, float(value) / scale))
|
|
103
|
+
return _to_words(_order(data, order))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def decode(words: list[int], kind: str, order: str = "ABCD", scale: float = 1.0):
|
|
107
|
+
"""Registers -> engineering value (inverse of :func:`encode`)."""
|
|
108
|
+
data = _from_words(words)
|
|
109
|
+
if kind == "string":
|
|
110
|
+
return data.rstrip(b"\0").decode("latin-1")
|
|
111
|
+
fmt, _ = _NUMERIC[kind]
|
|
112
|
+
raw = struct.unpack(fmt, _order(data, order))[0]
|
|
113
|
+
if scale == 1.0:
|
|
114
|
+
return raw if is_integer(kind) else float(raw)
|
|
115
|
+
return raw * scale
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fits(value: float, kind: str, scale: float = 1.0) -> bool:
|
|
119
|
+
"""True if `value` can be written to an integer register of `kind` without clamping."""
|
|
120
|
+
if not is_integer(kind):
|
|
121
|
+
return True
|
|
122
|
+
low, high = _RANGES[kind]
|
|
123
|
+
return low <= round(value / scale) <= high
|