hart-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.
hart_sim/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
hart_sim/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
hart_sim/bench.py ADDED
@@ -0,0 +1,163 @@
1
+ """Bench: several simulated HART devices behind one HART-IP server, from a YAML file."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from . import hartip
10
+ from .control import ControlServer
11
+ from .device import Device
12
+ from .profile import ProfileError, bundled_profiles, load_profile
13
+ from .server import Server
14
+
15
+ CONTROL_PORT = 5095
16
+ _BENCH_KEYS = {"profile", "polling_address", "device_id"}
17
+
18
+
19
+ class BenchError(ValueError):
20
+ """Invalid bench description; the message names the offending entry."""
21
+
22
+
23
+ def _int_in(value: Any, low: int, high: int, what: str, where: str) -> int:
24
+ if isinstance(value, bool) or not isinstance(value, int):
25
+ raise BenchError(f"{where}: {what} must be an integer, got {value!r}")
26
+ if not low <= value <= high:
27
+ raise BenchError(f"{where}: {what} {value} is outside {low}-{high}")
28
+ return value
29
+
30
+
31
+ class Bench:
32
+ def __init__(self) -> None:
33
+ self.devices: dict[str, Device] = {}
34
+ self.server: Server | None = None
35
+ self.control: ControlServer | None = None
36
+ self.control_port = 0
37
+ self._clients: list = []
38
+
39
+ # --- construction ---
40
+
41
+ @classmethod
42
+ def from_yaml(cls, path: str | Path) -> "Bench":
43
+ path = Path(path)
44
+ try:
45
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
46
+ except OSError as exc:
47
+ raise BenchError(f"{path}: {exc.strerror or exc}") from None
48
+ except UnicodeDecodeError:
49
+ raise BenchError(f"{path}: not a UTF-8 text file") from None
50
+ except yaml.YAMLError as exc:
51
+ raise BenchError(f"{path}: invalid YAML: {exc}") from None
52
+ return cls.from_dict(data, base=path.parent)
53
+
54
+ @classmethod
55
+ def single(cls, profile: str) -> "Bench":
56
+ """One device with the given profile at polling address 0."""
57
+ return cls.from_dict({"devices": [{"profile": profile, "polling_address": 0,
58
+ "device_id": 1}]})
59
+
60
+ @classmethod
61
+ def from_dict(cls, d: dict, base: Path | None = None) -> "Bench":
62
+ if not isinstance(d, dict) or not isinstance(d.get("devices"), list) or not d["devices"]:
63
+ raise BenchError("a bench is a mapping with a non-empty 'devices' list")
64
+ unknown = set(d) - {"devices"}
65
+ if unknown:
66
+ raise BenchError(f"unknown key {sorted(unknown)[0]!r} (allowed: devices)")
67
+ bench = cls()
68
+ polling: dict[int, str] = {}
69
+ ids: dict[tuple[int, int], str] = {}
70
+ for index, entry in enumerate(d["devices"], 1):
71
+ where = f"devices[{index}]"
72
+ if not isinstance(entry, dict):
73
+ raise BenchError(f"{where}: expected a mapping")
74
+ if "profile" not in entry:
75
+ raise BenchError(f"{where}: missing 'profile'")
76
+ profile_ref = entry["profile"]
77
+ if not isinstance(profile_ref, str):
78
+ raise BenchError(f"{where}: profile must be a name or file path")
79
+ for key in ("polling_address", "device_id"):
80
+ if key not in entry:
81
+ raise BenchError(f"{where}: missing {key!r}")
82
+ addr = _int_in(entry["polling_address"], 0, 63, "polling address", where)
83
+ dev_id = _int_in(entry["device_id"], 0, 0xFFFFFF, "device ID", where)
84
+ if base is not None and profile_ref not in bundled_profiles():
85
+ candidate = base / profile_ref
86
+ if candidate.is_file():
87
+ profile_ref = str(candidate)
88
+ overrides = {k: v for k, v in entry.items() if k not in _BENCH_KEYS}
89
+ try:
90
+ profile = load_profile(profile_ref, overrides)
91
+ except ProfileError as exc:
92
+ raise BenchError(f"{where}: {exc}") from None
93
+ device = Device(profile, polling_address=addr, device_id=dev_id)
94
+ label = f"{where} ({device.tag})"
95
+ if device.tag in bench.devices:
96
+ raise BenchError(f"{label}: duplicate tag {device.tag!r}")
97
+ if addr in polling:
98
+ raise BenchError(
99
+ f"{label}: duplicate polling address {addr} (also used by {polling[addr]})")
100
+ uid = (device.profile.device_type & 0x3FFF, dev_id)
101
+ if uid in ids:
102
+ raise BenchError(f"{label}: duplicate device ID {dev_id:#08x} "
103
+ f"(also used by {ids[uid]})")
104
+ bench.devices[device.tag] = device
105
+ polling[addr] = device.tag
106
+ ids[uid] = device.tag
107
+ return bench
108
+
109
+ # --- lifecycle ---
110
+
111
+ def start(self, host: str = "127.0.0.1", port: int = hartip.PORT,
112
+ control_port: int = CONTROL_PORT, control_host: str = "127.0.0.1") -> None:
113
+ """Start the HART-IP server and the control channel; port 0 picks a free port.
114
+
115
+ The control channel has no authentication, so it stays on loopback whatever
116
+ `host` is; pass `control_host` to expose it deliberately.
117
+ """
118
+ if self.server is not None:
119
+ raise BenchError("bench is already running")
120
+ server = Server(list(self.devices.values()), host=host, port=port)
121
+ server.start()
122
+ control = ControlServer(self, control_host, control_port)
123
+ try:
124
+ control.start()
125
+ except BaseException:
126
+ server.stop()
127
+ raise
128
+ self.server, self.control = server, control
129
+ self.control_port = control.port
130
+
131
+ def client(self, **kwargs):
132
+ """A connected HartIPClient for the running bench; closed when the bench stops.
133
+
134
+ Keyword arguments (``transport``, ``timeout``, ``primary``) go to HartIPClient.
135
+ """
136
+ from .client import HartIPClient
137
+ if self.server is None:
138
+ raise BenchError("bench is not running")
139
+ client = HartIPClient("127.0.0.1" if self.server.host in ("0.0.0.0", "") else self.server.host,
140
+ self.server.port, **kwargs)
141
+ client.open()
142
+ self._clients.append(client)
143
+ return client
144
+
145
+ def stop(self) -> None:
146
+ """Stop server and control channel; safe to call more than once."""
147
+ clients, self._clients = self._clients, []
148
+ for client in clients:
149
+ client.close()
150
+ control, server = self.control, self.server
151
+ self.control = self.server = None
152
+ if control is not None:
153
+ control.stop()
154
+ if server is not None:
155
+ server.stop()
156
+
157
+ def __enter__(self) -> "Bench":
158
+ """Start on free ports (0/0) so tests never collide; use start() for fixed ports."""
159
+ self.start(port=0, control_port=0)
160
+ return self
161
+
162
+ def __exit__(self, *exc) -> None:
163
+ self.stop()
hart_sim/cli.py ADDED
@@ -0,0 +1,313 @@
1
+ """Command line interface: ``hart-sim serve``, ``poll``, ``cmd``, ``inject``, ``clear``, ``set``."""
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__, hartip, units
12
+ from .bench import CONTROL_PORT, Bench, BenchError
13
+ from .client import DeviceHandle, HartConnectionError, HartError, HartIPClient, HartTimeout
14
+ from .control import send_control
15
+ from .frame import unpack_ascii, unpack_float
16
+ from .profile import ProfileError, bundled_profiles
17
+
18
+ # Response codes 8 and 14 (span too small) are warnings: the command was carried out.
19
+ _WARNING_CODES = (8, 14)
20
+ _KINDS = {0xFA01 & 0x3FFF: "pressure-transmitter", 0xFA02 & 0x3FFF: "temperature-transmitter"}
21
+
22
+
23
+ def _port(text: str) -> int:
24
+ try:
25
+ port = int(text)
26
+ except ValueError:
27
+ port = -1
28
+ if not 0 <= port <= 65535:
29
+ raise argparse.ArgumentTypeError(f"invalid port {text!r} (allowed: 0-65535)")
30
+ return port
31
+
32
+
33
+ def _polling_address(text: str) -> int:
34
+ try:
35
+ number = int(text)
36
+ except ValueError:
37
+ number = -1
38
+ if not 0 <= number <= 63:
39
+ raise argparse.ArgumentTypeError(f"invalid polling address {text!r} (allowed: 0-63)")
40
+ return number
41
+
42
+
43
+ def _parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(prog="hart-sim",
45
+ description="Simulated HART field devices speaking HART-IP.")
46
+ parser.add_argument("--version", action="version", version=f"hart-sim {__version__}")
47
+ commands = parser.add_subparsers(dest="command", required=True)
48
+
49
+ def client_args(p: argparse.ArgumentParser) -> None:
50
+ p.add_argument("--host", default="127.0.0.1", help="HART-IP server (default: 127.0.0.1)")
51
+ p.add_argument("--port", type=_port, default=hartip.PORT,
52
+ help=f"HART-IP port (default: {hartip.PORT})")
53
+ p.add_argument("--udp", action="store_true", help="use UDP instead of TCP")
54
+
55
+ def control_args(p: argparse.ArgumentParser) -> None:
56
+ p.add_argument("--host", default="127.0.0.1", help="bench address (default: 127.0.0.1)")
57
+ p.add_argument("--control-port", type=_port, default=CONTROL_PORT,
58
+ help=f"control port (default: {CONTROL_PORT})")
59
+
60
+ serve = commands.add_parser("serve", help="serve one bundled profile or a bench file")
61
+ serve.add_argument("profile", nargs="?", help="bundled profile name (see: hart-sim profiles)")
62
+ serve.add_argument("--bench", metavar="FILE", help="serve all devices of a bench file (YAML)")
63
+ serve.add_argument("--host", default="127.0.0.1", help="address to listen on (default: 127.0.0.1)")
64
+ serve.add_argument("--port", type=_port, default=hartip.PORT,
65
+ help=f"HART-IP port, TCP and UDP (default: {hartip.PORT}, 0 = any free port)")
66
+ serve.add_argument("--control-host", default="127.0.0.1",
67
+ help="address of the control port (default: 127.0.0.1, whatever --host is; "
68
+ "the control port has no authentication)")
69
+ serve.add_argument("--control-port", type=_port, default=CONTROL_PORT,
70
+ help=f"control port (default: {CONTROL_PORT}, 0 = any free port)")
71
+
72
+ poll = commands.add_parser("poll", help="list the devices that answer on the server")
73
+ poll.add_argument("--max-address", type=_polling_address, default=63, metavar="N",
74
+ help="poll addresses 0..N (default: 63)")
75
+ client_args(poll)
76
+
77
+ cmd = commands.add_parser("cmd", help="send one HART command to a device")
78
+ who = cmd.add_mutually_exclusive_group(required=True)
79
+ who.add_argument("--tag", help="device tag")
80
+ who.add_argument("--address", type=int, metavar="N", help="polling address 0-63")
81
+ cmd.add_argument("number", type=int, help="command number")
82
+ cmd.add_argument("hexdata", nargs="?", default="", help="request data as hex, e.g. 0102ff")
83
+ client_args(cmd)
84
+
85
+ inject = commands.add_parser("inject", help="inject a fault into a running bench")
86
+ inject.add_argument("tag")
87
+ inject.add_argument("kind", help="e.g. sensor_failure, saturate, busy, drift, no_response, delay")
88
+ inject.add_argument("params", nargs="*", metavar="KEY=VALUE", help="fault parameters, e.g. per_second=0.5 or direction=high")
89
+ control_args(inject)
90
+
91
+ clear = commands.add_parser("clear", help="clear one fault or all faults of a device")
92
+ clear.add_argument("tag")
93
+ clear.add_argument("kind", nargs="?", help="fault to clear (default: all)")
94
+ control_args(clear)
95
+
96
+ set_ = commands.add_parser("set", help="override the PV or SV of a device")
97
+ set_.add_argument("tag")
98
+ set_.add_argument("variable", choices=["pv", "sv"])
99
+ set_.add_argument("value", help="a number, or 'auto' to return to the simulated process")
100
+ control_args(set_)
101
+
102
+ commands.add_parser("profiles", help="list the bundled device profiles")
103
+ return parser
104
+
105
+
106
+ def _error(message: str) -> int:
107
+ print(f"hart-sim: error: {message}", file=sys.stderr)
108
+ return 2
109
+
110
+
111
+ # --- serve ---
112
+
113
+ def _stop_event() -> threading.Event:
114
+ """Set on SIGINT/SIGTERM. Installed before the announcement so no signal is lost."""
115
+ stop = threading.Event()
116
+ # Install SIGINT explicitly: a shell starts "hart-sim serve ... &" with SIGINT ignored.
117
+ for sig in (signal.SIGINT, signal.SIGTERM):
118
+ signal.signal(sig, lambda *_: stop.set())
119
+ return stop
120
+
121
+
122
+ def _serve(parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
123
+ if bool(args.profile) == bool(args.bench):
124
+ parser.error("serve needs exactly one of PROFILE or --bench FILE")
125
+ try:
126
+ bench = Bench.from_yaml(args.bench) if args.bench else Bench.single(args.profile)
127
+ except (BenchError, ProfileError) as exc:
128
+ return _error(str(exc))
129
+ try:
130
+ bench.start(host=args.host, port=args.port, control_port=args.control_port,
131
+ control_host=args.control_host)
132
+ except OSError as exc:
133
+ return _error(f"cannot listen on {args.host}: {exc.strerror or exc}")
134
+ stop = _stop_event()
135
+ count = len(bench.devices)
136
+ print(f"hart-sim: {count} device{'' if count == 1 else 's'} on HART-IP "
137
+ f"{args.host}:{bench.server.port} (tcp+udp), control on "
138
+ f"{args.control_host}:{bench.control_port}", flush=True)
139
+ while not stop.wait(0.5):
140
+ pass
141
+ bench.stop()
142
+ print("hart-sim: stopped", flush=True)
143
+ return 0
144
+
145
+
146
+ # --- client commands ---
147
+
148
+ def _kind(device_type: int) -> str:
149
+ return _KINDS.get(device_type & 0x3FFF, f"device type 0x{device_type & 0x3FFF:04X}")
150
+
151
+
152
+ def _fmt(value: float, digits: int) -> str:
153
+ return f"{value:.{digits}f}"
154
+
155
+
156
+ def _decode(number: int, data: bytes) -> str | None:
157
+ """Decoded one-line output for the universal read commands, None if not decodable."""
158
+ try:
159
+ if number == 1 and len(data) >= 5:
160
+ return f"PV {_fmt(unpack_float(data[1:5]), 1)} {units.name(data[0])}"
161
+ if number == 2 and len(data) >= 8:
162
+ return f"loop {_fmt(unpack_float(data[0:4]), 2)} mA | {_fmt(unpack_float(data[4:8]), 1)} %"
163
+ if number == 3 and len(data) >= 9:
164
+ text = (f"loop {_fmt(unpack_float(data[0:4]), 2)} mA | "
165
+ f"PV {_fmt(unpack_float(data[5:9]), 1)} {units.name(data[4])}")
166
+ if len(data) >= 14:
167
+ text += f" | SV {_fmt(unpack_float(data[10:14]), 1)} {units.name(data[9])}"
168
+ return text
169
+ if number == 13 and len(data) >= 21:
170
+ day, month, year = data[18], data[19], data[20] + 1900
171
+ return (f"tag {unpack_ascii(data[:6]).strip()} | descriptor "
172
+ f"{unpack_ascii(data[6:18]).strip()} | date {year:04d}-{month:02d}-{day:02d}")
173
+ if number == 15 and len(data) >= 17:
174
+ alarm = "high" if data[0] == 0 else "low" if data[0] == 1 else f"code {data[0]}"
175
+ unit = units.name(data[2])
176
+ return (f"range {_fmt(unpack_float(data[7:11]), 1)} .. {_fmt(unpack_float(data[3:7]), 1)} "
177
+ f"{unit} | damping {_fmt(unpack_float(data[11:15]), 2)} s | alarm {alarm} | "
178
+ f"write protect {'on' if data[15] else 'off'}")
179
+ if number == 48 and len(data) >= 6:
180
+ extended = f" | extended 0x{data[6]:02x}" if len(data) > 6 else ""
181
+ return f"status bytes {data[:6].hex(' ')}{extended}"
182
+ except ValueError: # e.g. an unit code this simulator does not know
183
+ return None
184
+ return None
185
+
186
+
187
+ def _cmd(parser: argparse.ArgumentParser, args: argparse.Namespace) -> int:
188
+ try:
189
+ payload = bytes.fromhex(args.hexdata)
190
+ except ValueError:
191
+ parser.error(f"HEXDATA {args.hexdata!r} is not a hex string")
192
+ if args.address is not None and not 0 <= args.address <= 63:
193
+ parser.error("--address must be 0-63")
194
+ if not 0 <= args.number <= 255:
195
+ parser.error("command number must be 0-255")
196
+ transport = "udp" if args.udp else "tcp"
197
+ client = HartIPClient(args.host, args.port, transport=transport)
198
+ try:
199
+ client.open()
200
+ except HartError as exc:
201
+ return _error(str(exc)) # connection refused or no session
202
+ try:
203
+ try:
204
+ if args.tag is not None:
205
+ handle = client.find(args.tag)
206
+ else:
207
+ handle = client.handle(args.address)
208
+ frame = client.command(handle.address, args.number, payload)
209
+ except LookupError as exc:
210
+ print(f"hart-sim: error: {exc}", file=sys.stderr)
211
+ return 1
212
+ except HartTimeout:
213
+ print("hart-sim: error: device did not answer", file=sys.stderr)
214
+ return 1
215
+ except HartConnectionError as exc:
216
+ return _error(str(exc)) # transport failure, like connection refused
217
+ except HartError as exc:
218
+ print(f"hart-sim: error: {exc}", file=sys.stderr)
219
+ return 1
220
+ if frame.response_code != 0:
221
+ print(f"code {frame.response_code} status 0x{frame.device_status:02x} "
222
+ f"data {frame.data.hex()}")
223
+ if frame.response_code in _WARNING_CODES: # applied, but with a remark
224
+ print(f"warning: code {frame.response_code}", file=sys.stderr)
225
+ return 0
226
+ print(f"hart-sim: error: command {args.number}: response code {frame.response_code}",
227
+ file=sys.stderr)
228
+ return 1
229
+ text = _decode(args.number, frame.data)
230
+ if text is None:
231
+ print(f"code {frame.response_code} status 0x{frame.device_status:02x} "
232
+ f"data {frame.data.hex()}")
233
+ return 0
234
+ if frame.device_status:
235
+ text = "status: " + ", ".join(DeviceHandle.status_text(frame.device_status)) + " | " + text
236
+ if frame.device_status & 0x01 and args.number in (1, 3):
237
+ text = text.replace(" | SV", " (bad) | SV") if " | SV" in text else text + " (bad)"
238
+ print(text)
239
+ return 0
240
+ finally:
241
+ client.close()
242
+
243
+
244
+ def _poll(args: argparse.Namespace) -> int:
245
+ client = HartIPClient(args.host, args.port, transport="udp" if args.udp else "tcp")
246
+ try:
247
+ with client:
248
+ found = client.poll(max_address=args.max_address)
249
+ except HartError as exc:
250
+ return _error(str(exc))
251
+ for info in found:
252
+ print(f"addr {info.polling_address} {info.tag} {_kind(info.device_type)} "
253
+ f"id 0x{info.device_id:06x}")
254
+ return 0
255
+
256
+
257
+ # --- control commands ---
258
+
259
+ def _value(text: str) -> Any:
260
+ try:
261
+ return json.loads(text)
262
+ except ValueError:
263
+ return text
264
+
265
+
266
+ def _control(args: argparse.Namespace, request: dict) -> int:
267
+ try:
268
+ answer = send_control(request, host=args.host, port=args.control_port)
269
+ except OSError as exc:
270
+ return _error(f"cannot connect to control port {args.host}:{args.control_port}: "
271
+ f"{getattr(exc, 'strerror', None) or exc}")
272
+ if not answer.get("ok"):
273
+ print(f"hart-sim: error: {answer.get('error')}", file=sys.stderr)
274
+ return 1
275
+ return 0
276
+
277
+
278
+ def main(argv: list[str] | None = None) -> int:
279
+ parser = _parser()
280
+ args = parser.parse_args(argv)
281
+ if args.command == "serve":
282
+ return _serve(parser, args)
283
+ if args.command in ("poll", "cmd"):
284
+ try:
285
+ return _poll(args) if args.command == "poll" else _cmd(parser, args)
286
+ except OSError as exc: # anything the client did not turn into a HartError
287
+ return _error(getattr(exc, "strerror", None) or str(exc))
288
+ if args.command == "profiles":
289
+ for name in bundled_profiles():
290
+ print(name)
291
+ return 0
292
+ if args.command == "inject":
293
+ params = {}
294
+ for item in args.params:
295
+ key, sep, value = item.partition("=")
296
+ if not sep or not key:
297
+ parser.error(f"parameter {item!r} is not KEY=VALUE")
298
+ params[key] = _value(value)
299
+ return _control(args, {"device": args.tag, "inject": args.kind, "params": params})
300
+ if args.command == "clear":
301
+ return _control(args, {"device": args.tag, "clear": args.kind or True})
302
+ if args.value == "auto":
303
+ value = None
304
+ else:
305
+ try:
306
+ value = float(args.value)
307
+ except ValueError:
308
+ parser.error(f"value {args.value!r} is not a number or 'auto'")
309
+ return _control(args, {"device": args.tag, "set": {args.variable: value}})
310
+
311
+
312
+ if __name__ == "__main__":
313
+ sys.exit(main())