scpi-sim 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. scpi_sim-0.3.0/CHANGELOG.md +75 -0
  2. scpi_sim-0.3.0/LICENSE +21 -0
  3. scpi_sim-0.3.0/MANIFEST.in +6 -0
  4. scpi_sim-0.3.0/PKG-INFO +556 -0
  5. scpi_sim-0.3.0/README.md +520 -0
  6. scpi_sim-0.3.0/docs/demo.tape +28 -0
  7. scpi_sim-0.3.0/examples/bench.yaml +18 -0
  8. scpi_sim-0.3.0/examples/devices/bench.yaml +18 -0
  9. scpi_sim-0.3.0/examples/devices/sensor_board.py +79 -0
  10. scpi_sim-0.3.0/examples/devices/test_sensor_board.py +62 -0
  11. scpi_sim-0.3.0/examples/test_example.py +53 -0
  12. scpi_sim-0.3.0/pyproject.toml +52 -0
  13. scpi_sim-0.3.0/setup.cfg +4 -0
  14. scpi_sim-0.3.0/src/scpi_sim/__init__.py +15 -0
  15. scpi_sim-0.3.0/src/scpi_sim/bench.py +534 -0
  16. scpi_sim-0.3.0/src/scpi_sim/cli.py +226 -0
  17. scpi_sim-0.3.0/src/scpi_sim/console.py +62 -0
  18. scpi_sim-0.3.0/src/scpi_sim/control.py +132 -0
  19. scpi_sim-0.3.0/src/scpi_sim/device.py +97 -0
  20. scpi_sim-0.3.0/src/scpi_sim/errors.py +33 -0
  21. scpi_sim-0.3.0/src/scpi_sim/faults.py +115 -0
  22. scpi_sim-0.3.0/src/scpi_sim/instrument.py +273 -0
  23. scpi_sim-0.3.0/src/scpi_sim/instruments/__init__.py +5 -0
  24. scpi_sim-0.3.0/src/scpi_sim/instruments/dmm.py +114 -0
  25. scpi_sim-0.3.0/src/scpi_sim/instruments/mux.py +67 -0
  26. scpi_sim-0.3.0/src/scpi_sim/instruments/psu.py +153 -0
  27. scpi_sim-0.3.0/src/scpi_sim/loads.py +73 -0
  28. scpi_sim-0.3.0/src/scpi_sim/pytest_plugin.py +89 -0
  29. scpi_sim-0.3.0/src/scpi_sim/scpi/__init__.py +0 -0
  30. scpi_sim-0.3.0/src/scpi_sim/scpi/params.py +75 -0
  31. scpi_sim-0.3.0/src/scpi_sim/scpi/parser.py +99 -0
  32. scpi_sim-0.3.0/src/scpi_sim/scpi/pattern.py +87 -0
  33. scpi_sim-0.3.0/src/scpi_sim/transport.py +161 -0
  34. scpi_sim-0.3.0/src/scpi_sim.egg-info/PKG-INFO +556 -0
  35. scpi_sim-0.3.0/src/scpi_sim.egg-info/SOURCES.txt +60 -0
  36. scpi_sim-0.3.0/src/scpi_sim.egg-info/dependency_links.txt +1 -0
  37. scpi_sim-0.3.0/src/scpi_sim.egg-info/entry_points.txt +5 -0
  38. scpi_sim-0.3.0/src/scpi_sim.egg-info/requires.txt +8 -0
  39. scpi_sim-0.3.0/src/scpi_sim.egg-info/top_level.txt +1 -0
  40. scpi_sim-0.3.0/tests/conftest.py +16 -0
  41. scpi_sim-0.3.0/tests/helpers.py +157 -0
  42. scpi_sim-0.3.0/tests/test_bench.py +210 -0
  43. scpi_sim-0.3.0/tests/test_bench_devices.py +167 -0
  44. scpi_sim-0.3.0/tests/test_bench_threads.py +78 -0
  45. scpi_sim-0.3.0/tests/test_cli.py +228 -0
  46. scpi_sim-0.3.0/tests/test_console.py +125 -0
  47. scpi_sim-0.3.0/tests/test_control.py +99 -0
  48. scpi_sim-0.3.0/tests/test_device.py +85 -0
  49. scpi_sim-0.3.0/tests/test_dmm.py +102 -0
  50. scpi_sim-0.3.0/tests/test_errors.py +18 -0
  51. scpi_sim-0.3.0/tests/test_faults.py +168 -0
  52. scpi_sim-0.3.0/tests/test_instrument.py +124 -0
  53. scpi_sim-0.3.0/tests/test_loads.py +50 -0
  54. scpi_sim-0.3.0/tests/test_mux.py +53 -0
  55. scpi_sim-0.3.0/tests/test_packaging.py +109 -0
  56. scpi_sim-0.3.0/tests/test_params.py +56 -0
  57. scpi_sim-0.3.0/tests/test_parser.py +53 -0
  58. scpi_sim-0.3.0/tests/test_pattern.py +36 -0
  59. scpi_sim-0.3.0/tests/test_plugin.py +118 -0
  60. scpi_sim-0.3.0/tests/test_psu.py +120 -0
  61. scpi_sim-0.3.0/tests/test_pyvisa.py +16 -0
  62. scpi_sim-0.3.0/tests/test_transport.py +116 -0
@@ -0,0 +1,75 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## 0.3.0 - 2026-10-26
8
+
9
+ ### Added
10
+
11
+ - **Devices**: your own device under test as a Python class
12
+ (`scpi_sim.Device`) with input terminals that draw current and output
13
+ terminals that drive their net; `update()` after every instrument message
14
+ lets it react to power-on (inrush, boot timelines). Configured under
15
+ `devices:` in the bench file.
16
+ - **Device console**: a raw TCP port per device (`console:` in the bench
17
+ file), broadcast to every client, typed lines go to `console_line()`;
18
+ pyserial reaches it as `socket://host:port` (`Bench.console_url()`).
19
+ - Example device in `examples/devices` (sensor board with boot log and
20
+ console) and the README section "Writing your own device".
21
+ - **Docker image**: `Dockerfile` (non-root, example bench by default); CI
22
+ builds and smoke-tests it, releases publish `ghcr.io/steitec/scpi-sim`.
23
+
24
+ ### Changed
25
+
26
+ - `InstrumentServer` is now built on a small `TcpServer` base class shared
27
+ with the console server.
28
+ - `Instrument.listeners`: callables run after every message, outside the
29
+ instrument lock (the bench uses it to update devices). A `no_response`
30
+ fault now also runs them.
31
+ - `Bench.stop()` stops every server even if one of them fails.
32
+
33
+ ## 0.2.0 - 2026-10-26
34
+
35
+ ### Added
36
+
37
+ - **Bench**: `scpi-sim serve --bench FILE` starts several instruments from a
38
+ YAML file and wires them together — the multimeter measures, through closed
39
+ multiplexer relays, what the power supply delivers into its loads.
40
+ - Loads: resistors (`ohms: 0` is a short), parallel loads and custom Python
41
+ classes with a `current(volts)` curve; the supply finds the CV/CC
42
+ operating point for any of them.
43
+ - A relay that would connect two supply outputs is refused with
44
+ `-221,"Settings conflict"`.
45
+ - Bench files are checked strictly: duplicate keys, wrong option types and
46
+ unknown terminals are reported with the offending key or line.
47
+ - Python loads are imported from the bench file's folder as well.
48
+ - The control port is refused if it clashes with an instrument port, closes
49
+ its connections when the bench stops and works on IPv6 (`--host ::1`).
50
+ - The source distribution contains the tests, examples and changelog and can
51
+ run its own test suite.
52
+ - **Fault injection**: `no_response`, `delay`, `disconnect`, `error`, `idn`,
53
+ `offset`, `noise` — from Python (`instrument.inject(...)`), from pytest, or
54
+ into a running bench with `scpi-sim fault` through a JSON control port.
55
+ - **pytest plugin**: `scpi_bench` and `scpi_bench_factory` fixtures,
56
+ configured with `--scpi-bench FILE` or the `scpi_bench` ini key.
57
+ - Examples in `examples/` (bench file and a pytest suite using PyVISA).
58
+ - Release workflow for PyPI (Trusted Publishing).
59
+
60
+ ### Changed
61
+
62
+ - PyYAML is now a runtime dependency.
63
+ - `PowerSupply` takes any `load=` object; `load_ohms=` still works.
64
+
65
+ ## 0.1.0 - 2026-10-26
66
+
67
+ ### Added
68
+
69
+ - Generic multimeter, power supply and multiplexer on raw TCP sockets
70
+ (`scpi-sim serve dmm|psu|mux`).
71
+ - SCPI parser with short/long forms, optional nodes, numeric suffixes and the
72
+ SCPI path rule; IEEE 488.2 common commands, error queue and status registers.
73
+ - `scpi-sim query`, a small cross-platform client.
74
+ - Python API (`InstrumentServer`, `Instrument` with the `@scpi` decorator).
75
+
scpi_sim-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ralf Steinert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # What goes into the source distribution besides the package itself
2
+ include CHANGELOG.md LICENSE README.md
3
+ graft tests
4
+ graft examples
5
+ include docs/demo.tape
6
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,556 @@
1
+ Metadata-Version: 2.4
2
+ Name: scpi-sim
3
+ Version: 0.3.0
4
+ Summary: Simulated SCPI lab instruments (DMM, power supply, multiplexer) on a raw TCP socket
5
+ Author: Ralf Steinert
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/steitec/scpi-sim
8
+ Project-URL: Repository, https://github.com/steitec/scpi-sim
9
+ Project-URL: Changelog, https://github.com/steitec/scpi-sim/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/steitec/scpi-sim/issues
11
+ Keywords: scpi,simulator,test automation,pyvisa,instrument,lab
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Manufacturing
15
+ Classifier: Framework :: Pytest
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Classifier: Topic :: Software Development :: Testing
24
+ Classifier: Topic :: System :: Hardware
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: pyyaml>=6
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8; extra == "dev"
31
+ Requires-Dist: pyvisa>=1.13; extra == "dev"
32
+ Requires-Dist: pyvisa-py>=0.7; extra == "dev"
33
+ Requires-Dist: pyserial>=3.5; extra == "dev"
34
+ Requires-Dist: build>=1.2; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # scpi-sim
38
+
39
+ Simulated SCPI lab instruments — a multimeter, a power supply and a relay
40
+ multiplexer — that listen on a raw TCP socket like real LAN instruments on
41
+ port 5025. Develop and test instrument automation without the hardware.
42
+
43
+ ![Demo: a simulated power supply driven from the command line](https://raw.githubusercontent.com/steitec/scpi-sim/main/docs/demo.gif)
44
+
45
+ ```console
46
+ $ scpi-sim serve psu --load 24 &
47
+ scpi-sim: PSU listening on 127.0.0.1:5025 (Ctrl+C to stop)
48
+ $ scpi-sim query "*IDN?" "VOLT 12;CURR 1;:OUTP ON;:MEAS:CURR?" "VOLT 40" "SYST:ERR?"
49
+ scpi-sim,PSU,SIM00002,0.2.0
50
+ +5.00000000E-01
51
+ -222,"Data out of range"
52
+ ```
53
+
54
+ ## Why
55
+
56
+ Test software for lab instruments is usually written while the instruments
57
+ are busy, on someone else's bench, or not bought yet — and it is rarely tested
58
+ in CI at all. scpi-sim gives you instruments that:
59
+
60
+ - **are real network servers** — anything that can open a TCP socket can talk
61
+ to them: PyVISA (`TCPIP0::127.0.0.1::5025::SOCKET`), C#, LabVIEW, NI MAX,
62
+ `nc`, your own code
63
+ - **keep state** like the real thing — settings persist, `*RST` resets them,
64
+ errors go to the error queue, status registers (`*ESR?`, `*STB?`) work
65
+ - **behave physically** — the power supply goes from constant voltage into
66
+ current limit when its load draws too much, the multimeter has ranges and
67
+ reports overload (`+9.90000000E+37`) when a reading does not fit
68
+ - **are strict where real instruments are strict** — short/long forms only,
69
+ the SCPI path rule in compound commands, standard error codes — so bugs in
70
+ your command strings show up before they meet the hardware
71
+ - **work together on a bench** — wire a supply, a multiplexer and a
72
+ multimeter in a YAML file, and the multimeter measures what the supply
73
+ delivers into its load
74
+ - **break on purpose** — inject timeouts, dropped connections, wrong
75
+ identities or drifting readings at runtime, and see whether your test code
76
+ copes
77
+
78
+ ### Compared with pyvisa-sim
79
+
80
+ [pyvisa-sim](https://github.com/pyvisa/pyvisa-sim) is a PyVISA backend that
81
+ simulates devices described in YAML, inside your Python process. It is a good
82
+ fit for unit tests of PyVISA code. scpi-sim takes a different approach:
83
+
84
+ | | pyvisa-sim | scpi-sim |
85
+ |---|---|---|
86
+ | Runs as | PyVISA backend in your process | TCP server, one port per instrument |
87
+ | Usable from | Python with PyVISA | any language or tool |
88
+ | Devices defined in | YAML | Python classes (generic instruments included) |
89
+
90
+ On top of that, scpi-sim has physical behaviour (CV/CC into a load, ranges,
91
+ overload), instruments wired together on a bench, fault injection at runtime
92
+ and a pytest plugin.
93
+
94
+ ## Quick start
95
+
96
+ Requires Python 3.10 or newer; the only dependency is PyYAML.
97
+
98
+ ```bash
99
+ git clone https://github.com/steitec/scpi-sim.git && cd scpi-sim
100
+ pip install -e .
101
+
102
+ scpi-sim serve dmm # multimeter on 127.0.0.1:5025
103
+ ```
104
+
105
+ From another terminal, with the built-in client (works on Linux, macOS and
106
+ Windows):
107
+
108
+ ```console
109
+ $ scpi-sim query "*IDN?"
110
+ scpi-sim,DMM,SIM00001,0.2.0
111
+ ```
112
+
113
+ `scpi-sim query [--host HOST] [--port PORT] MESSAGE...` sends each message
114
+ and prints the answer to every message that contains a `?`.
115
+
116
+ Any raw TCP client works as well, e.g. `nc`:
117
+
118
+ ```bash
119
+ echo '*IDN?' | nc -q1 localhost 5025 # Linux (netcat-openbsd)
120
+ echo '*IDN?' | nc -N localhost 5025 # macOS
121
+ ```
122
+
123
+ Every message must end with a newline (`\n`), as on real LAN instruments:
124
+ `printf '*IDN?' | nc …` without one gets no answer.
125
+
126
+ With PyVISA and the pure-Python backend (`pip install pyvisa pyvisa-py`):
127
+
128
+ ```python
129
+ import pyvisa
130
+
131
+ rm = pyvisa.ResourceManager("@py")
132
+ dmm = rm.open_resource("TCPIP0::127.0.0.1::5025::SOCKET",
133
+ read_termination="\n", write_termination="\n")
134
+ print(dmm.query("MEAS:VOLT:DC?"))
135
+ ```
136
+
137
+ Run several instruments side by side on different ports (`scpi-sim query
138
+ --port 5026 …` talks to the second one):
139
+
140
+ ```bash
141
+ scpi-sim serve psu --port 5025 --channels 2 --load 24
142
+ scpi-sim serve dmm --port 5026 --volts 3.3
143
+ scpi-sim serve mux --port 5027
144
+ ```
145
+
146
+ The server listens on `127.0.0.1` by default; use `--host 0.0.0.0` to make it
147
+ reachable from other machines. A host name that resolves to several addresses
148
+ (`localhost` → `127.0.0.1` and `::1`) is served on all of them, on the same
149
+ port.
150
+
151
+ ## Instruments
152
+
153
+ All instruments support the IEEE 488.2 common commands `*IDN?`, `*RST`,
154
+ `*CLS`, `*ESR?`, `*ESE`, `*ESE?`, `*SRE`, `*SRE?`, `*STB?`, `*OPC`, `*OPC?`,
155
+ `*WAI`, `*TST?` and `SYSTem:ERRor[:NEXT]?`, `SYSTem:ERRor:COUNt?`,
156
+ `SYSTem:VERSion?`.
157
+
158
+ ### Multimeter — `scpi-sim serve dmm`
159
+
160
+ | Command | |
161
+ |---|---|
162
+ | `MEASure:VOLTage[:DC]? [range]` | DC voltage |
163
+ | `MEASure:CURRent[:DC]? [range]` | DC current |
164
+ | `MEASure:RESistance? [range]` | 2-wire resistance |
165
+ | `CONFigure:VOLTage[:DC] [range]`, `CONFigure:CURRent[:DC] [range]`, `CONFigure:RESistance [range]` | select function and range |
166
+ | `CONFigure?` | e.g. `"VOLT AUTO"`, `"RES +1.00000000E+04"` |
167
+ | `READ?` | measure with the current configuration |
168
+
169
+ Ranges: 0.1/1/10/100/1000 V, 0.01/0.1/1/3/10 A, 100 Ω … 100 MΩ in decades.
170
+ `range` is `AUTO`, `DEF`, `MIN`, `MAX` or a value (the smallest range that
171
+ holds its magnitude is selected); an optional second parameter (resolution)
172
+ is accepted. A reading above 120 % of the range returns `+9.90000000E+37`
173
+ (`-9.90000000E+37` for negative readings).
174
+
175
+ Options: `--volts 5.0`, `--amps 0.1`, `--ohms 1000` (the simulated input),
176
+ `--noise 50e-6` (relative standard deviation, `0` for exact readings),
177
+ `--seed N` (reproducible noise).
178
+
179
+ ### Power supply — `scpi-sim serve psu`
180
+
181
+ | Command | |
182
+ |---|---|
183
+ | `[SOURce[n]:]VOLTage[:LEVel][:IMMediate][:AMPLitude] <v>` / `…? [MIN\|MAX\|DEF]` | voltage setpoint, 0–30 V |
184
+ | `[SOURce[n]:]CURRent[:LEVel][:IMMediate][:AMPLitude] <a>` / `…? [MIN\|MAX\|DEF]` | current limit, 0–3 A |
185
+ | `OUTPut[n][:STATe] ON\|OFF` / `OUTPut[n][:STATe]?` | output on/off, `n` = channel |
186
+ | `OUTPut[n]:MODE?` | `OFF`, `CV` or `CC` |
187
+ | `MEASure:VOLTage[:DC]?`, `MEASure:CURRent[:DC]?` | at the output terminals |
188
+ | `INSTrument:NSELect <n>` / `?`, `INSTrument[:SELect] CH<n>` / `?` | select channel |
189
+
190
+ `SOURce<n>` and `OUTPut<n>` address channel `n` directly; without a number
191
+ the channel selected with `INSTrument` is used. `*RST` state: 0 V, 0.1 A,
192
+ output off, channel 1 selected. Values accept units
193
+ (`500 mV`, `250 mA`).
194
+
195
+ Options: `--channels 1`, `--load OHMS` (resistive load on every output;
196
+ without it the outputs are open and deliver no current; `--load 0` is a short
197
+ circuit, which drives the output straight into the current limit).
198
+
199
+ ### Multiplexer — `scpi-sim serve mux`
200
+
201
+ | Command | |
202
+ |---|---|
203
+ | `ROUTe:CLOSe (@101,103:105)` | close channels |
204
+ | `ROUTe:OPEN (@…)`, `ROUTe:OPEN:ALL` | open channels |
205
+ | `ROUTe:CLOSe? (@…)`, `ROUTe:OPEN? (@…)` | one `1`/`0` per channel |
206
+ | `ROUTe:CLOSe:STATe?` | closed channels, e.g. `(@101,105)` |
207
+
208
+ Channels are numbered 101, 102, … A list with an invalid channel is rejected
209
+ as a whole. Options: `--channels 16` (1–99).
210
+
211
+ ## Bench
212
+
213
+ A bench file starts several instruments at once and wires them together:
214
+
215
+ ```yaml
216
+ # examples/bench.yaml
217
+ instruments:
218
+ psu: {type: psu, port: 5025, channels: 2}
219
+ dmm: {type: dmm, port: 5026}
220
+ mux: {type: mux, port: 5027, channels: 16}
221
+ loads:
222
+ rail_12v: {type: resistor, ohms: 24} # 0.5 A at 12 V
223
+ rail_3v3: {type: resistor, ohms: 33} # 0.1 A at 3.3 V
224
+ wiring:
225
+ - psu.ch1 -> rail_12v
226
+ - psu.ch2 -> rail_3v3
227
+ - mux.101 -> rail_12v
228
+ - mux.102 -> rail_3v3
229
+ - mux.common -> dmm.input
230
+ control: 5099
231
+ ```
232
+
233
+ ```console
234
+ $ scpi-sim serve --bench examples/bench.yaml
235
+ scpi-sim: psu (PSU) listening on 127.0.0.1:5025
236
+ scpi-sim: dmm (DMM) listening on 127.0.0.1:5026
237
+ scpi-sim: mux (MUX) listening on 127.0.0.1:5027
238
+ scpi-sim: control on 127.0.0.1:5099
239
+ scpi-sim: bench running (Ctrl+C to stop)
240
+ ```
241
+
242
+ ```console
243
+ $ scpi-sim query "SOUR1:VOLT 12;:SOUR1:CURR 1;:OUTP1 ON;*OPC?"
244
+ 1
245
+ $ scpi-sim query --port 5027 "ROUT:CLOS (@101);*OPC?"
246
+ 1
247
+ $ scpi-sim query --port 5026 "MEAS:VOLT?"
248
+ +1.20000310E+01
249
+ $ scpi-sim query "INST:NSEL 1;:MEAS:CURR?"
250
+ +5.00000000E-01
251
+ ```
252
+
253
+ - **Instruments**: `type` is `psu`, `dmm` or `mux`, `port` is the TCP port
254
+ (`0` = any free port); the other keys are the options of that instrument
255
+ (`psu`: `channels`, `max_volts`, `max_amps`; `dmm`: `volts`, `amps`, `ohms`,
256
+ `noise`, `seed`; `mux`: `channels`).
257
+ - **Wiring**: each line `A -> B` connects two terminals: `<psu>.ch<n>`,
258
+ `<dmm>.input`, `<mux>.<channel>`, `<mux>.common` or a node name of your
259
+ choice. A closed multiplexer relay connects its channel to `common`.
260
+ - **Loads** sit on nodes: `{type: resistor, ohms: 24}` (`ohms: 0` is a short),
261
+ or your own Python class with a `current(volts)` method, e.g. a diode or a
262
+ device that draws constant current:
263
+ `{type: python, class: "mypackage.loads:Lamp", args: {amps: 0.3}}`. The
264
+ supply finds the operating point (constant voltage, or current limit) for
265
+ any such load.
266
+ - The multimeter measures the voltage of the net its input is on (0 V if
267
+ nothing drives it) and the resistance of the resistors on it.
268
+ - Two supply outputs never end up on the same net: in the file that is an
269
+ error, and a `ROUT:CLOS` that would connect them is refused with
270
+ `-221,"Settings conflict"`.
271
+ - **Devices**: your own device under test, written in Python, with its own
272
+ terminals and an optional console port — see
273
+ [Writing your own device](#writing-your-own-device).
274
+ - Mistakes in the file are reported with the offending line and exit code 2.
275
+
276
+ Every instrument has its own connection, so a command sent to one
277
+ instrument can still be in flight when you query another one. Finish
278
+ commands with `*OPC?` before measuring elsewhere, as you would on a real
279
+ bench (`ROUT:CLOS (@101);*OPC?`).
280
+
281
+ ## Writing your own device
282
+
283
+ A device under test is a Python class with terminals: an **input** draws
284
+ current from the net it is wired to, an **output** drives its net like a
285
+ supply. The bench calls `update()` after every instrument message, so the
286
+ device notices when its supply switches on, and it can talk over a console
287
+ port like a serial line behind a terminal server.
288
+
289
+ ```python
290
+ import threading, time
291
+ from scpi_sim import Device
292
+
293
+ class SensorBoard(Device):
294
+ terminals = {"vin": "input", "3V3": "output"}
295
+
296
+ def __init__(self, serial="SB-00-0001"):
297
+ super().__init__() # required: sets up console and bench hooks
298
+ self.serial, self.powered_at, self.generation = serial, None, 0
299
+
300
+ def update(self): # after every instrument message
301
+ powered = self.input_voltage("vin") >= 4.5
302
+ if powered and self.powered_at is None:
303
+ self.powered_at = time.monotonic()
304
+ self.generation += 1
305
+ threading.Thread(target=self.boot, args=(self.generation,), daemon=True).start()
306
+ elif not powered and self.powered_at is not None:
307
+ self.powered_at = None
308
+ self.generation += 1 # a boot still running is now stale
309
+
310
+ def boot(self, generation): # own thread: may sleep
311
+ time.sleep(1.0)
312
+ if generation == self.generation: # not switched off meanwhile
313
+ self.console.write("ready\nsensor> ")
314
+
315
+ def current(self, terminal, volts): # amps drawn on an input
316
+ powered_at = self.powered_at # read once, update() runs in another thread
317
+ if powered_at is None:
318
+ return 0.0
319
+ return 0.4 if time.monotonic() - powered_at < 0.2 else 0.08
320
+
321
+ def voltage(self, terminal): # volts driven on an output
322
+ return 3.3 if self.input_voltage("vin") >= 4.5 else 0.0
323
+
324
+ def console_line(self, line): # a line typed on the console
325
+ if line == "id?":
326
+ self.console.write(self.serial + "\nsensor> ")
327
+ ```
328
+
329
+ ```yaml
330
+ devices:
331
+ board:
332
+ class: "sensor_board:SensorBoard" # module next to the bench file, or installed
333
+ args: {serial: "SB-24-0042"}
334
+ console: 5028 # optional: console on this TCP port
335
+ wiring:
336
+ - psu.ch1 -> board.vin
337
+ - mux.101 -> board.3V3
338
+ - mux.common -> dmm.input
339
+ ```
340
+
341
+ | Member | Called by | What it does |
342
+ |---|---|---|
343
+ | `terminals` | — | `{"name": "input" \| "output"}`; wired as `<device>.<name>` |
344
+ | `current(terminal, volts)` | the supply | amps drawn on an input at that voltage |
345
+ | `voltage(terminal)` | the bench | volts driven on an output; `None` reads as 0 V (the output still counts as a driver of its net) |
346
+ | `update()` | after every instrument message | look at `input_voltage()`, start timelines |
347
+ | `input_voltage(terminal)` | your code | voltage of the net an input is on |
348
+ | `console.write(text)` | your code | send text to everyone connected to the console |
349
+ | `console_line(line)` | the console port | a line someone typed |
350
+
351
+ Rules that keep a bench responsive:
352
+
353
+ - `current()`, `voltage()` and `update()` run in the instruments' threads —
354
+ never block in them. Start a thread for anything that takes time (boot
355
+ sequence) and compute time-dependent values from `time.monotonic()`.
356
+ `console_line()` runs on the console's event loop: don't block there either.
357
+ - The bench runs one `update()` at a time, but `current()` and `voltage()` can
358
+ run while `update()` or your own threads change the device's state: read
359
+ shared attributes once into a local, and use a lock for state that changes
360
+ in several steps.
361
+ - Call `super().__init__()` in your `__init__` and write `str` to the console.
362
+ - `current()` must not call `input_voltage()`: the input voltage is what the
363
+ supply computes from `current()`.
364
+ - A device output and a supply (or two outputs) on one net is an error, like
365
+ two supplies; a relay that would connect them is refused with `-221`.
366
+ - An exception in `current()`/`voltage()` becomes `-300` in the error queue of
367
+ the instrument that asked; one in `update()` or `console_line()` is printed
368
+ once and ignored.
369
+
370
+ Reach the console with any raw TCP client (`nc localhost 5028`) or with
371
+ pyserial, as `serial.serial_for_url(bench.console_url("board"))`. The complete
372
+ example is in
373
+ [`examples/devices`](https://github.com/steitec/scpi-sim/tree/main/examples/devices):
374
+ `pytest examples/devices --scpi-bench examples/devices/bench.yaml`.
375
+
376
+ ## Fault injection
377
+
378
+ Faults are injected at runtime — from Python, from pytest, or into a running
379
+ bench through its control port:
380
+
381
+ ```console
382
+ $ scpi-sim fault dmm noise sigma=0.05
383
+ ok
384
+ $ scpi-sim fault psu no_response count=2
385
+ ok
386
+ $ scpi-sim fault --list
387
+ psu: no_response count=2
388
+ dmm: noise sigma=0.05
389
+ mux: -
390
+ $ scpi-sim fault dmm --clear
391
+ ok
392
+ ```
393
+
394
+ | Fault | Parameters | Effect |
395
+ |---|---|---|
396
+ | `no_response` | `count=1` | the next `count` answers are not sent (the commands still execute) |
397
+ | `delay` | `seconds`, `count` (default: until cleared) | answers are sent `seconds` later |
398
+ | `disconnect` | — | the next message closes the connection |
399
+ | `error` | `code`, `message` | one entry is pushed into the error queue |
400
+ | `idn` | `value` | `*IDN?` answers `value` |
401
+ | `offset` | `value` | multimeter: added to every reading |
402
+ | `noise` | `sigma` | multimeter: replaces the noise of the readings |
403
+
404
+ Faults stay active until cleared or used up (`no_response`, `disconnect` and
405
+ `delay` with a `count`); `*RST` and `*CLS` do not clear them. They belong to
406
+ the instrument, not to a connection. From Python:
407
+ `bench.dmm.inject("offset", value=0.2)`, `bench.dmm.clear_faults()`.
408
+
409
+ The control port speaks one JSON object per line:
410
+ `{"instrument": "dmm", "inject": "noise", "params": {"sigma": 0.05}}`,
411
+ `{"instrument": "dmm", "clear": true}`, `{"list": true}`. It has no
412
+ authentication: with `--host 0.0.0.0` anyone who can reach the port can
413
+ inject faults, so expose a bench only on a network you trust.
414
+
415
+ ## pytest plugin
416
+
417
+ Installing scpi-sim registers a pytest plugin. The `scpi_bench` fixture
418
+ starts a fresh bench for every test, on free ports, and stops it afterwards:
419
+
420
+ ```python
421
+ import pyvisa
422
+ import pytest
423
+
424
+
425
+ def test_dead_multimeter_raises_timeout(scpi_bench):
426
+ rm = pyvisa.ResourceManager("@py")
427
+ dmm = rm.open_resource(scpi_bench.resource("dmm"), read_termination="\n",
428
+ write_termination="\n", timeout=500)
429
+ scpi_bench.dmm.inject("no_response")
430
+ with pytest.raises(pyvisa.errors.VisaIOError):
431
+ dmm.query("MEAS:VOLT?")
432
+ dmm.close()
433
+ rm.close()
434
+ ```
435
+
436
+ - The bench comes from `--scpi-bench FILE`, or from the ini key
437
+ `scpi_bench = FILE` (relative to the rootdir); without either it is a
438
+ generic `psu`, `dmm` and `mux`, not wired.
439
+ - `scpi_bench.resource(name)` gives the VISA resource string,
440
+ `scpi_bench.port(name)` the port, `scpi_bench.<name>` the instrument object.
441
+ - `scpi_bench_factory(path_or_dict)` starts further benches; a relative path
442
+ is relative to the rootdir, like the ini key.
443
+
444
+ [`examples/test_example.py`](https://github.com/steitec/scpi-sim/blob/main/examples/test_example.py) is a complete example:
445
+ `pytest examples/test_example.py --scpi-bench examples/bench.yaml`.
446
+
447
+ ## SCPI behaviour
448
+
449
+ - Keywords match in short or long form, case-insensitive (`MEAS`,
450
+ `measure`) — but nothing in between (`MEASU` is an error), like on real
451
+ instruments. Optional nodes in `[ ]` may be left out.
452
+ - Compound commands follow the SCPI path rule: after `;` a command continues
453
+ at the level of the previous one, so `SOUR:VOLT 5;CURR 1` sets
454
+ `SOUR:CURR`. Start with `:` to go back to the root:
455
+ `MEAS:VOLT?;:MEAS:CURR?` works, `MEAS:VOLT?;MEAS:CURR?` gives
456
+ `-113,"Undefined header"` — exactly as a real instrument would.
457
+ - Several queries in one message are answered in one line, separated by `;`.
458
+ - `*RST` resets the instrument settings; `*CLS` clears the error queue and
459
+ the event status register. The error queue holds 20 entries; on overflow
460
+ the last one becomes `-350,"Queue overflow"`.
461
+
462
+ Error codes:
463
+
464
+ | Code | Message |
465
+ |---|---|
466
+ | -102 | Syntax error |
467
+ | -104 | Data type error |
468
+ | -108 | Parameter not allowed (more parameters than the command takes) |
469
+ | -109 | Missing parameter |
470
+ | -113 | Undefined header |
471
+ | -114 | Header suffix out of range |
472
+ | -131 | Invalid suffix |
473
+ | -222 | Data out of range |
474
+ | -223 | Too much data (line longer than 64 KiB) |
475
+ | -224 | Illegal parameter value |
476
+ | -300 | Device-specific error |
477
+ | -350 | Queue overflow |
478
+
479
+ ## Docker
480
+
481
+ The image runs the example bench (supply on 5025, multimeter on 5026,
482
+ multiplexer on 5027, control port on 5099):
483
+
484
+ ```bash
485
+ docker run --rm -p 5025-5027:5025-5027 -p 127.0.0.1:5099:5099 ghcr.io/steitec/scpi-sim
486
+ ```
487
+
488
+ Your own bench file (devices in Python modules next to it are found too):
489
+
490
+ ```bash
491
+ docker run --rm -p 5025-5028:5025-5028 -v "$PWD/bench":/bench ghcr.io/steitec/scpi-sim
492
+ ```
493
+
494
+ The control port has no authentication, hence `127.0.0.1:` in front of it.
495
+ Any other command works as well, e.g.
496
+ `docker run --rm -p 5025:5025 ghcr.io/steitec/scpi-sim serve dmm --host 0.0.0.0`.
497
+ In CI, start it as a service container and point your tests at
498
+ `TCPIP0::localhost::5025::SOCKET`. The image is published with each release;
499
+ until then build it yourself with `docker build -t scpi-sim .`.
500
+
501
+ ## Python API
502
+
503
+ Start instruments from your own tests, on a free port:
504
+
505
+ ```python
506
+ import pyvisa
507
+
508
+ from scpi_sim import InstrumentServer, PowerSupply
509
+
510
+ with InstrumentServer(PowerSupply(load_ohms=24), port=0) as server:
511
+ rm = pyvisa.ResourceManager("@py")
512
+ psu = rm.open_resource(f"TCPIP0::127.0.0.1::{server.port}::SOCKET",
513
+ read_termination="\n", write_termination="\n")
514
+ psu.write("VOLT 12;CURR 1;:OUTP ON")
515
+ print(psu.query("MEAS:CURR?")) # +5.00000000E-01
516
+ psu.write("CURR 0.25")
517
+ print(psu.query("MEAS:VOLT?;:OUTP:MODE?")) # +6.00000000E+00;CC
518
+ ```
519
+
520
+ The simulated multimeter input can be changed from Python while a test runs:
521
+ `dmm = Multimeter(noise=0)`, then `dmm.volts = 1.8`.
522
+
523
+ A bench from Python:
524
+
525
+ ```python
526
+ from scpi_sim import Bench
527
+
528
+ with Bench.from_file("examples/bench.yaml") as bench: # starts all servers
529
+ print(bench.resource("dmm")) # TCPIP0::127.0.0.1::5026::SOCKET
530
+ bench.psu.process("SOUR1:VOLT 12;:SOUR1:CURR 1;:OUTP1 ON")
531
+ bench.mux.process("ROUT:CLOS (@101)")
532
+ print(bench.dmm.process("MEAS:VOLT?")) # about +1.2000E+01
533
+ ```
534
+
535
+ Your own instrument is a subclass of `Instrument` with one method per
536
+ command:
537
+
538
+ ```python
539
+ from scpi_sim import Instrument, scpi
540
+
541
+ class Thermometer(Instrument):
542
+ model, serial = "THERMO", "SIM00042"
543
+
544
+ @scpi("MEASure:TEMPerature?")
545
+ def measure(self, call):
546
+ return 23.5
547
+ ```
548
+
549
+ ## Roadmap
550
+
551
+ - **later** — profiles that behave similar to common bench instruments,
552
+ VXI-11, a simple oscilloscope
553
+
554
+ ## License
555
+
556
+ MIT