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/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)
@@ -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