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 ADDED
@@ -0,0 +1,11 @@
1
+ """Simulated Modbus devices (energy meter, temperature controller, frequency converter)
2
+ with behaviour and fault injection, over Modbus TCP, RTU over TCP and serial RTU."""
3
+ __version__ = "0.1.0"
4
+
5
+ from .bench import Bench, BenchError
6
+ from .client import ModbusClient, ModbusError
7
+ from .device import Device
8
+ from .profile import ProfileError, load_profile
9
+
10
+ __all__ = ["Bench", "BenchError", "Device", "ModbusClient", "ModbusError", "ProfileError",
11
+ "load_profile", "__version__"]
modbus_sim/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
modbus_sim/bench.py ADDED
@@ -0,0 +1,218 @@
1
+ """Bench: several simulated devices (unit IDs) behind one server, from a YAML file.
2
+
3
+ ::
4
+
5
+ devices:
6
+ - {profile: energy-meter, unit: 1, name: meter}
7
+ - {profile: temperature-controller, unit: 2, name: oven}
8
+ - {profile: vfd, unit: 3, name: pump, model_params: {accel_time: 5}}
9
+
10
+ Besides ``profile``, ``unit`` and ``name``, an entry takes the profile overrides
11
+ ``word_order``, ``gaps``, ``model_params``, ``identification`` and ``values``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+
19
+ import yaml
20
+
21
+ from .control import CONTROL_PORT, ControlServer
22
+ from .device import Device
23
+ from .profile import ProfileError, bundled_profiles, load_profile
24
+ from .server import TCP_PORT, SerialServer, TcpServer
25
+
26
+ _ENTRY_KEYS = {"profile", "unit", "name"}
27
+
28
+
29
+ class BenchError(ValueError):
30
+ """Invalid bench description; the message names the offending entry."""
31
+
32
+
33
+ class Bench:
34
+ def __init__(self) -> None:
35
+ self.devices: dict[str, Device] = {}
36
+ self.server: TcpServer | SerialServer | None = None
37
+ self.control: ControlServer | None = None
38
+ self.control_port = 0
39
+
40
+ # --- construction ---
41
+
42
+ @classmethod
43
+ def from_yaml(cls, path: str | Path, clock: Callable[[], float] = time.monotonic
44
+ ) -> "Bench":
45
+ path = Path(path)
46
+ try:
47
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
48
+ except OSError as exc:
49
+ raise BenchError(f"{path}: {exc.strerror or exc}") from None
50
+ except UnicodeDecodeError:
51
+ raise BenchError(f"{path}: not a UTF-8 text file") from None
52
+ except yaml.YAMLError as exc:
53
+ raise BenchError(f"{path}: invalid YAML: {exc}") from None
54
+ return cls.from_dict(data, base=path.parent, clock=clock)
55
+
56
+ @classmethod
57
+ def single(cls, profile: str, unit: int = 1,
58
+ clock: Callable[[], float] = time.monotonic) -> "Bench":
59
+ return cls.from_dict({"devices": [{"profile": profile, "unit": unit}]}, clock=clock)
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: Any, base: Path | None = None,
63
+ clock: Callable[[], float] = time.monotonic) -> "Bench":
64
+ if not isinstance(data, dict) or not isinstance(data.get("devices"), list) \
65
+ or not data["devices"]:
66
+ raise BenchError("a bench is a mapping with a non-empty 'devices' list")
67
+ unknown = set(data) - {"devices"}
68
+ if unknown:
69
+ raise BenchError(f"unknown key {sorted(unknown)[0]!r} (allowed: devices)")
70
+ bench = cls()
71
+ units: dict[int, str] = {}
72
+ entries = []
73
+ for index, entry in enumerate(data["devices"], 1):
74
+ where = f"devices[{index}]"
75
+ if not isinstance(entry, dict):
76
+ raise BenchError(f"{where}: expected a mapping")
77
+ ref = entry.get("profile")
78
+ if not isinstance(ref, str):
79
+ raise BenchError(f"{where}: missing 'profile' (a bundled name or a file)")
80
+ unit = entry.get("unit")
81
+ if isinstance(unit, bool) or not isinstance(unit, int) or not 1 <= unit <= 247:
82
+ raise BenchError(f"{where}: unit must be an integer 1-247, got {unit!r}")
83
+ if unit in units:
84
+ raise BenchError(f"{where}: unit {unit} is already used by {units[unit]}")
85
+ name = entry.get("name")
86
+ if name is not None and (not isinstance(name, str) or not name.strip()):
87
+ raise BenchError(f"{where}: name must be a non-empty string")
88
+ if base is not None and ref not in bundled_profiles():
89
+ candidate = base / ref
90
+ if candidate.is_file():
91
+ ref = str(candidate)
92
+ overrides = {k: v for k, v in entry.items() if k not in _ENTRY_KEYS}
93
+ try:
94
+ profile = load_profile(ref, overrides)
95
+ except ProfileError as exc:
96
+ raise BenchError(f"{where}: {exc}") from None
97
+ units[unit] = name or profile.name
98
+ entries.append((where, profile, unit, name))
99
+ names = [p.name for _, p, _, n in entries if n is None]
100
+ for where, profile, unit, name in entries:
101
+ if name is None:
102
+ name = profile.name if names.count(profile.name) == 1 else \
103
+ f"{profile.name}-{unit}"
104
+ if name in bench.devices:
105
+ raise BenchError(f"{where}: duplicate name {name!r}")
106
+ try:
107
+ bench.devices[name] = Device(profile, unit=unit, name=name, clock=clock)
108
+ except ProfileError as exc:
109
+ raise BenchError(f"{where}: {exc}") from None
110
+ return bench
111
+
112
+ def find(self, ref: Any) -> Device:
113
+ """A device by name or unit ID."""
114
+ if isinstance(ref, str) and ref in self.devices:
115
+ return self.devices[ref]
116
+ if isinstance(ref, str) and ref.isdigit():
117
+ ref = int(ref)
118
+ if isinstance(ref, int) and not isinstance(ref, bool):
119
+ for device in self.devices.values():
120
+ if device.unit == ref:
121
+ return device
122
+ raise ValueError(f"unknown device {ref!r} (bench has: "
123
+ + ", ".join(f"{n} (unit {d.unit})" for n, d in self.devices.items())
124
+ + ")")
125
+
126
+ # --- lifecycle ---
127
+
128
+ def start(self, host: str = "127.0.0.1", port: int = TCP_PORT, framing: str = "tcp",
129
+ serial: str | None = None, pty: bool = False, baudrate: int = 19200,
130
+ parity: str = "E", stopbits: int = 1, control_port: int | None = CONTROL_PORT,
131
+ control_host: str = "127.0.0.1") -> None:
132
+ """Start the Modbus server and the control channel; port 0 picks a free port.
133
+
134
+ `framing` is "tcp" or "rtu" (RTU over TCP). With `pty=True` or a `serial`
135
+ port the devices speak RTU on that line instead of TCP. The control channel
136
+ has no authentication, so it stays on loopback unless `control_host` says
137
+ otherwise; `control_port=None` leaves it off.
138
+ """
139
+ if self.server is not None:
140
+ raise BenchError("bench is already running")
141
+ devices = list(self.devices.values())
142
+ if pty or serial:
143
+ server: TcpServer | SerialServer = SerialServer(
144
+ devices, port=serial, baudrate=baudrate, parity=parity, stopbits=stopbits)
145
+ else:
146
+ server = TcpServer(devices, host=host, port=port, framing=framing)
147
+ server.start()
148
+ self.server = server
149
+ if control_port is not None:
150
+ control = ControlServer(self, host=control_host, port=control_port)
151
+ try:
152
+ self.control_port = control.start()
153
+ except OSError:
154
+ server.stop()
155
+ self.server = None
156
+ raise
157
+ self.control = control
158
+
159
+ def stop(self) -> None:
160
+ if self.control is not None:
161
+ self.control.stop()
162
+ self.control = None
163
+ if self.server is not None:
164
+ self.server.stop()
165
+ self.server = None
166
+
167
+ def __enter__(self) -> "Bench":
168
+ if self.server is None:
169
+ self.start(port=0, control_port=0)
170
+ return self
171
+
172
+ def __exit__(self, *exc) -> None:
173
+ self.stop()
174
+
175
+ @property
176
+ def port(self) -> int | None:
177
+ return getattr(self.server, "port", None)
178
+
179
+ @property
180
+ def path(self) -> str | None:
181
+ """The serial device of an RTU bench (e.g. /dev/pts/7)."""
182
+ return getattr(self.server, "path", None)
183
+
184
+ def client(self, timeout: float = 2.0):
185
+ """A connected :class:`~modbus_sim.client.ModbusClient` for this bench."""
186
+ from .client import ModbusClient
187
+ if isinstance(self.server, SerialServer):
188
+ return ModbusClient.serial(self.server.path, timeout=timeout).connect()
189
+ if self.server is None:
190
+ raise BenchError("bench is not running")
191
+ host = self.server.host
192
+ if host in ("0.0.0.0", ""):
193
+ host = "127.0.0.1"
194
+ elif host == "::":
195
+ host = "::1"
196
+ return ModbusClient(host, self.server.port, timeout=timeout,
197
+ framing=self.server.framing).connect()
198
+
199
+ def inject(self, device: str | int, kind: str, **params: Any) -> None:
200
+ self.find(device).faults.inject(kind, **params)
201
+
202
+ def clear(self, device: str | int, kind: str | None = None) -> None:
203
+ self.find(device).faults.clear(kind)
204
+
205
+ def value(self, device: str | int, point: str) -> Any:
206
+ dev = self.find(device)
207
+ with dev.lock:
208
+ return ControlServer._point(dev, point).value()
209
+
210
+ def describe(self) -> str:
211
+ units = ", ".join(str(d.unit) for d in self.devices.values())
212
+ n = len(self.devices)
213
+ text = f"{n} device{'s' if n != 1 else ''} (unit{'s' if n != 1 else ''} {units})"
214
+ if self.server is not None:
215
+ text += f" on {self.server.describe()}"
216
+ if self.control is not None:
217
+ text += f", control on :{self.control_port}"
218
+ return text
modbus_sim/cli.py ADDED
@@ -0,0 +1,373 @@
1
+ """Command line: serve devices, talk to them, inject faults."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import signal
7
+ import sys
8
+ import threading
9
+ from typing import Any
10
+
11
+ from . import __version__
12
+ from . import datatypes as dt
13
+ from .bench import Bench, BenchError
14
+ from .client import ModbusClient, ModbusError, ProtocolError
15
+ from .control import CONTROL_PORT, send_control
16
+ from .profile import ProfileError, bundled_profiles, load_profile
17
+ from .server import TCP_PORT
18
+
19
+ _TABLE_REF = {"coil": 0, "discrete": 100000, "input": 300000, "holding": 400000}
20
+ _TABLE_ALIASES = {"coil": "coil", "coils": "coil", "co": "coil",
21
+ "discrete": "discrete", "di": "discrete", "discrete-inputs": "discrete",
22
+ "holding": "holding", "hr": "holding", "holding-registers": "holding",
23
+ "input": "input", "ir": "input", "input-registers": "input"}
24
+
25
+
26
+ class CliError(Exception):
27
+ """A usage or input error; printed without a traceback, exit code 2."""
28
+
29
+
30
+ def _int(text: str) -> int:
31
+ return int(text, 0)
32
+
33
+
34
+ def _table(text: str) -> str:
35
+ try:
36
+ return _TABLE_ALIASES[text.lower()]
37
+ except KeyError:
38
+ raise argparse.ArgumentTypeError(
39
+ "table is coil, discrete, holding or input (or co, di, hr, ir)") from None
40
+
41
+
42
+ def _value(text: str) -> Any:
43
+ try:
44
+ return json.loads(text)
45
+ except ValueError:
46
+ return text
47
+
48
+
49
+ def _pairs(items: list[str]) -> dict[str, Any]:
50
+ result = {}
51
+ for item in items:
52
+ key, sep, value = item.partition("=")
53
+ if not sep or not key:
54
+ raise CliError(f"modbus-sim: expected name=value, got {item!r}")
55
+ result[key] = _value(value)
56
+ return result
57
+
58
+
59
+ def _add_connection(p: argparse.ArgumentParser) -> None:
60
+ p.add_argument("--host", default="127.0.0.1")
61
+ p.add_argument("--port", type=int, default=TCP_PORT)
62
+ p.add_argument("--rtu-over-tcp", action="store_true", help="RTU framing over TCP")
63
+ p.add_argument("--serial", metavar="DEVICE", help="RTU on a serial port or pty instead")
64
+ p.add_argument("--baud", type=int, default=19200)
65
+ p.add_argument("--parity", choices=["N", "E", "O"], default="E")
66
+ p.add_argument("--unit", "-u", type=int, default=1, help="unit ID (default 1)")
67
+ p.add_argument("--timeout", type=float, default=2.0)
68
+
69
+
70
+ def _add_value_format(p: argparse.ArgumentParser) -> None:
71
+ p.add_argument("--type", "-t", default="uint16", choices=dt.REGISTER_TYPES,
72
+ help="value type for registers (default uint16)")
73
+ p.add_argument("--order", "-o", default="ABCD", choices=dt.WORD_ORDERS,
74
+ help="word order of 32/64-bit values (default ABCD)")
75
+ p.add_argument("--scale", type=float, default=1.0, help="engineering = raw * scale")
76
+
77
+
78
+ def _add_control(p: argparse.ArgumentParser) -> None:
79
+ p.add_argument("--control-host", default="127.0.0.1")
80
+ p.add_argument("--control-port", type=int, default=CONTROL_PORT)
81
+
82
+
83
+ def build_parser() -> argparse.ArgumentParser:
84
+ parser = argparse.ArgumentParser(
85
+ prog="modbus-sim", description="Simulated Modbus devices with behaviour and faults.")
86
+ parser.add_argument("--version", action="version", version=f"modbus-sim {__version__}")
87
+ sub = parser.add_subparsers(dest="command", required=True)
88
+
89
+ p = sub.add_parser("serve", help="run simulated devices")
90
+ p.add_argument("profile", nargs="?", help="bundled profile or profile file (one device)")
91
+ p.add_argument("--bench", metavar="FILE", help="bench file with several devices")
92
+ p.add_argument("--unit", "-u", type=int, default=1, help="unit ID for a single profile")
93
+ p.add_argument("--host", default="127.0.0.1",
94
+ help="address to listen on (0.0.0.0 for the network)")
95
+ p.add_argument("--port", type=int, default=TCP_PORT, help=f"TCP port (default {TCP_PORT})")
96
+ p.add_argument("--rtu-over-tcp", action="store_true", help="RTU framing over TCP")
97
+ p.add_argument("--pty", action="store_true", help="RTU on a new virtual serial port")
98
+ p.add_argument("--serial", metavar="DEVICE", help="RTU on a serial port (needs pyserial)")
99
+ p.add_argument("--baud", type=int, default=19200)
100
+ p.add_argument("--parity", choices=["N", "E", "O"], default="E")
101
+ p.add_argument("--stopbits", type=int, choices=[1, 2], default=1)
102
+ p.add_argument("--control-port", type=int, default=CONTROL_PORT)
103
+ p.add_argument("--control-host", default="127.0.0.1",
104
+ help="the control channel has no authentication; keep it on loopback")
105
+ p.add_argument("--no-control", action="store_true", help="no control channel")
106
+
107
+ sub.add_parser("profiles", help="list the bundled profiles")
108
+
109
+ p = sub.add_parser("map", help="print the register map of a profile")
110
+ p.add_argument("profile")
111
+ p.add_argument("--markdown", action="store_true", help="as a Markdown table")
112
+
113
+ p = sub.add_parser("read", help="read from a device")
114
+ p.add_argument("table", type=_table, help="coil, discrete, holding or input")
115
+ p.add_argument("address", type=_int, help="0-based protocol address (0x.. ok)")
116
+ p.add_argument("--count", "-c", type=int, default=1, help="number of values")
117
+ _add_connection(p)
118
+ _add_value_format(p)
119
+
120
+ p = sub.add_parser("write", help="write to a device (coil or holding)")
121
+ p.add_argument("table", type=_table)
122
+ p.add_argument("address", type=_int)
123
+ p.add_argument("values", nargs="+", help="values (numbers; 0/1 for coils)")
124
+ _add_connection(p)
125
+ _add_value_format(p)
126
+
127
+ p = sub.add_parser("ident", help="read the device identification (function 43/14)")
128
+ _add_connection(p)
129
+
130
+ p = sub.add_parser("inject", help="inject a fault into a running bench")
131
+ p.add_argument("device", help="device name or unit ID")
132
+ p.add_argument("fault")
133
+ p.add_argument("params", nargs="*", help="name=value, e.g. code=4 count=3")
134
+ _add_control(p)
135
+
136
+ p = sub.add_parser("clear", help="clear faults (all, or one kind)")
137
+ p.add_argument("device")
138
+ p.add_argument("fault", nargs="?")
139
+ _add_control(p)
140
+
141
+ p = sub.add_parser("get", help="show point values of a running device")
142
+ p.add_argument("device")
143
+ p.add_argument("points", nargs="*", help="point names (default: all)")
144
+ _add_control(p)
145
+
146
+ p = sub.add_parser("set", help="override point values (name=value, null = release)")
147
+ p.add_argument("device")
148
+ p.add_argument("values", nargs="+")
149
+ _add_control(p)
150
+
151
+ p = sub.add_parser("list", help="devices and active faults of a running bench")
152
+ _add_control(p)
153
+ return parser
154
+
155
+
156
+ # --- commands -----------------------------------------------------------------------
157
+
158
+ def _serve(args: argparse.Namespace) -> int:
159
+ if bool(args.profile) == bool(args.bench):
160
+ raise CliError("modbus-sim serve: give a profile or --bench FILE")
161
+ if sum(bool(x) for x in (args.pty, args.serial, args.rtu_over_tcp)) > 1:
162
+ raise CliError("modbus-sim serve: choose one of --pty, --serial, --rtu-over-tcp")
163
+ bench = Bench.from_yaml(args.bench) if args.bench else Bench.single(args.profile, args.unit)
164
+ bench.start(host=args.host, port=args.port,
165
+ framing="rtu" if args.rtu_over_tcp else "tcp", serial=args.serial,
166
+ pty=args.pty, baudrate=args.baud, parity=args.parity, stopbits=args.stopbits,
167
+ control_port=None if args.no_control else args.control_port,
168
+ control_host=args.control_host)
169
+ print(f"modbus-sim: {bench.describe()}", flush=True)
170
+ for name, device in bench.devices.items():
171
+ print(f" unit {device.unit:3} {name} ({device.profile.name})", flush=True)
172
+ stop = threading.Event()
173
+ for sig in (signal.SIGINT, signal.SIGTERM):
174
+ try:
175
+ signal.signal(sig, lambda *_: stop.set())
176
+ except (ValueError, OSError): # not in the main thread / not on this platform
177
+ pass
178
+ try:
179
+ while not stop.wait(0.5):
180
+ pass
181
+ finally:
182
+ bench.stop()
183
+ return 0
184
+
185
+
186
+ def _profiles(_args) -> int:
187
+ for name in bundled_profiles():
188
+ print(f"{name:24} {load_profile(name).description}")
189
+ return 0
190
+
191
+
192
+ def _map(args) -> int:
193
+ profile = load_profile(args.profile)
194
+ rows = []
195
+ for s in sorted(profile.points, key=lambda s: (list(_TABLE_REF).index(s.table), s.address)):
196
+ kind = s.type + (f"[{s.length}]" if s.length else "")
197
+ if s.count > 1 and s.type != "string":
198
+ kind += f" {s.word_order}"
199
+ limits = ""
200
+ if s.min is not None or s.max is not None:
201
+ limits = f"{'' if s.min is None else f'{s.min:g}'}..{'' if s.max is None else f'{s.max:g}'}"
202
+ note = s.description
203
+ if s.bits:
204
+ note = ", ".join(f"{bit}: {text}" for bit, text in sorted(s.bits.items()))
205
+ rows.append([s.table, str(s.address), f"{_TABLE_REF[s.table] + s.address + 1:06d}",
206
+ s.name, kind, "" if s.scale == 1 else f"{s.scale:g}", s.unit, s.access,
207
+ limits, note])
208
+ header = ["table", "address", "ref", "point", "type", "scale", "unit", "access",
209
+ "limits", "notes"]
210
+ print(f"{profile.name}: {profile.description}")
211
+ print(f"identification: {profile.identification.get('vendor')} "
212
+ f"{profile.identification.get('product')} {profile.identification.get('revision')}"
213
+ f"; unmapped addresses: {'exception 02' if profile.gaps == 'error' else 'read 0'}")
214
+ print()
215
+ if args.markdown:
216
+ print("| " + " | ".join(header) + " |")
217
+ print("|" + "---|" * len(header))
218
+ for row in rows:
219
+ print("| " + " | ".join(c.replace("|", "\\|") for c in row) + " |")
220
+ else:
221
+ widths = [max(len(r[i]) for r in rows + [header]) for i in range(len(header))]
222
+ for row in [header] + rows:
223
+ print(" ".join(c.ljust(w) for c, w in zip(row, widths)).rstrip())
224
+ return 0
225
+
226
+
227
+ def _client(args) -> ModbusClient:
228
+ if args.serial:
229
+ return ModbusClient.serial(args.serial, args.baud, args.parity, timeout=args.timeout)
230
+ return ModbusClient(args.host, args.port, timeout=args.timeout,
231
+ framing="rtu" if args.rtu_over_tcp else "tcp")
232
+
233
+
234
+ def _read(args) -> int:
235
+ with _client(args) as c:
236
+ if args.table in ("coil", "discrete"):
237
+ read = c.read_coils if args.table == "coil" else c.read_discrete_inputs
238
+ for i, bit in enumerate(read(args.address, args.count, args.unit)):
239
+ print(f"{args.table} {args.address + i}: {int(bit)}")
240
+ return 0
241
+ size = dt.registers(args.type, 1 if args.type == "string" else None)
242
+ total = args.count if args.type == "string" else args.count * size
243
+ read = c.read_holding_registers if args.table == "holding" else c.read_input_registers
244
+ words = read(args.address, total, args.unit)
245
+ if args.type == "string":
246
+ print(f"{args.table} {args.address}: {dt.decode(words, 'string')!r}")
247
+ return 0
248
+ for i in range(args.count):
249
+ part = words[i * size:(i + 1) * size]
250
+ value = dt.decode(part, args.type, args.order, args.scale)
251
+ raw = " ".join(f"{w:04x}" for w in part)
252
+ shown = f"{value:g}" if isinstance(value, float) else str(value)
253
+ print(f"{args.table} {args.address + i * size}: {shown} [{raw}]")
254
+ return 0
255
+
256
+
257
+ def _write(args) -> int:
258
+ if args.table not in ("coil", "holding"):
259
+ raise CliError("modbus-sim write: only coil and holding can be written")
260
+ with _client(args) as c:
261
+ if args.table == "coil":
262
+ bits = []
263
+ for v in args.values:
264
+ if v not in ("0", "1", "true", "false", "on", "off"):
265
+ raise CliError(f"modbus-sim write: coil value {v!r} is not 0/1")
266
+ bits.append(v in ("1", "true", "on"))
267
+ if len(bits) == 1:
268
+ c.write_coil(args.address, bits[0], args.unit)
269
+ else:
270
+ c.write_coils(args.address, bits, args.unit)
271
+ return 0
272
+ words: list[int] = []
273
+ for v in args.values:
274
+ if args.type == "string":
275
+ words += dt.encode(v, "string", length=(len(v.encode("latin-1", "replace"))
276
+ + 1) // 2)
277
+ continue
278
+ try:
279
+ number = float(int(v, 0)) if v.lower().startswith(("0x", "-0x")) else float(v)
280
+ except ValueError:
281
+ raise CliError(f"modbus-sim write: {v!r} is not a number") from None
282
+ if dt.is_integer(args.type) and not dt.fits(number, args.type, args.scale):
283
+ raise CliError(f"modbus-sim write: {v} does not fit {args.type}")
284
+ words += dt.encode(number, args.type, args.order, args.scale)
285
+ if len(words) == 1:
286
+ c.write_register(args.address, words[0], args.unit)
287
+ else:
288
+ c.write_registers(args.address, words, args.unit)
289
+ return 0
290
+
291
+
292
+ _ID_NAMES = ["vendor", "product code", "revision", "vendor URL", "product name", "model name",
293
+ "user application name"]
294
+
295
+
296
+ def _ident(args) -> int:
297
+ with _client(args) as c:
298
+ for oid, text in sorted(c.read_device_identification(args.unit).items()):
299
+ name = _ID_NAMES[oid] if oid < len(_ID_NAMES) else f"object {oid:#04x}"
300
+ print(f"{name}: {text}")
301
+ return 0
302
+
303
+
304
+ def _control(args, request: dict) -> dict:
305
+ answer = send_control(request, args.control_host, args.control_port)
306
+ if not answer.get("ok"):
307
+ raise CliError(f"modbus-sim: {answer.get('error')}")
308
+ return answer
309
+
310
+
311
+ def _device_ref(text: str) -> Any:
312
+ return int(text) if text.isdigit() else text
313
+
314
+
315
+ def _inject(args) -> int:
316
+ _control(args, {"device": _device_ref(args.device), "inject": args.fault,
317
+ "params": _pairs(args.params)})
318
+ return 0
319
+
320
+
321
+ def _clear(args) -> int:
322
+ _control(args, {"device": _device_ref(args.device), "clear": args.fault or True})
323
+ return 0
324
+
325
+
326
+ def _get(args) -> int:
327
+ answer = _control(args, {"device": _device_ref(args.device), "get": args.points or True})
328
+ for name, value in answer["values"].items():
329
+ print(f"{name}: {value:g}" if isinstance(value, float) else f"{name}: {value}")
330
+ return 0
331
+
332
+
333
+ def _set(args) -> int:
334
+ _control(args, {"device": _device_ref(args.device), "set": _pairs(args.values)})
335
+ return 0
336
+
337
+
338
+ def _list(args) -> int:
339
+ answer = _control(args, {"list": True})
340
+ for name, info in answer["devices"].items():
341
+ faults = ", ".join(f"{k}{'' if not v else ' ' + json.dumps(v)}"
342
+ for k, v in info["faults"].items()) or "no faults"
343
+ print(f"unit {info['unit']:3} {name} ({info['profile']}) "
344
+ f"{info['requests']} requests {faults}")
345
+ return 0
346
+
347
+
348
+ _COMMANDS = {"serve": _serve, "profiles": _profiles, "map": _map, "read": _read,
349
+ "write": _write, "ident": _ident, "inject": _inject, "clear": _clear,
350
+ "get": _get, "set": _set, "list": _list}
351
+
352
+
353
+ def main(argv: list[str] | None = None) -> int:
354
+ args = build_parser().parse_args(argv)
355
+ try:
356
+ return _COMMANDS[args.command](args)
357
+ except CliError as exc:
358
+ message = str(exc)
359
+ print(message if message.startswith("modbus-sim") else f"modbus-sim: {message}",
360
+ file=sys.stderr)
361
+ return 2
362
+ except (BenchError, ProfileError) as exc:
363
+ print(f"modbus-sim: {exc}", file=sys.stderr)
364
+ return 2
365
+ except ModbusError as exc:
366
+ print(f"modbus-sim: {exc}", file=sys.stderr)
367
+ return 1
368
+ except (ProtocolError, TimeoutError) as exc:
369
+ print(f"modbus-sim: {exc}", file=sys.stderr)
370
+ return 1
371
+ except OSError as exc:
372
+ print(f"modbus-sim: {exc}", file=sys.stderr)
373
+ return 1