benchmesh 0.1.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.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ sweep-*.csv
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BenchMesh Team
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,284 @@
1
+ Metadata-Version: 2.5
2
+ Name: benchmesh
3
+ Version: 0.1.0
4
+ Summary: Python client for BenchMesh: control and record lab instruments (power supplies, meters, loads, generators, scopes) from scripts and notebooks
5
+ Project-URL: Homepage, https://benchmesh.online
6
+ Project-URL: Documentation, https://benchmesh.online/docs/python
7
+ Project-URL: Repository, https://github.com/BenchMesh/benchmesh-python
8
+ Project-URL: Issues, https://github.com/BenchMesh/benchmesh-python/issues
9
+ Author-email: BenchMesh Team <support@benchmesh.online>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: automation,benchmesh,instruments,lab,multimeter,power-supply,scpi,test-equipment
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Scientific/Engineering
25
+ Classifier: Topic :: System :: Hardware
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Provides-Extra: pandas
29
+ Requires-Dist: pandas>=1.3; extra == 'pandas'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # benchmesh
33
+
34
+ Python client for [BenchMesh](https://benchmesh.online), one cockpit for lab instruments:
35
+ power supplies, multimeters, electronic loads, signal generators, oscilloscopes, LCR meters and
36
+ spectrum analyzers. Control them, read them and record them from scripts and Jupyter notebooks.
37
+
38
+ - No runtime dependencies (standard library only). Python 3.9 or newer.
39
+ - One API for every instrument of a class: the limits, ranges and modes come from the server.
40
+ - Live readings, recordings into pandas, automation flows, remote access with a pinned certificate.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install benchmesh # the client
46
+ pip install "benchmesh[pandas]" # plus pandas for DataFrames
47
+ ```
48
+
49
+ The BenchMesh app (or `benchmesh-server`) must be running. The client talks to its API at
50
+ `http://127.0.0.1:57666` on the same computer.
51
+
52
+ ## Quick start
53
+
54
+ ```python
55
+ import benchmesh
56
+
57
+ bench = benchmesh.connect() # $BENCHMESH_URL or http://127.0.0.1:57666
58
+
59
+ for inst in bench.instruments():
60
+ print(inst.id, inst.classes, inst.model, "online" if inst.online else "offline")
61
+
62
+ psu = bench["tenma-72-2540"] # the id from the Instruments drawer
63
+ psu.set("voltage", 5.0) # setter, checked by the server against 0-60 V
64
+ psu.set("current", 0.5)
65
+ psu.set("output", "ON") # switches the output ON at once
66
+ print(psu.read("VOUT")) # latest polled value: 4.94 (float, volts)
67
+ print(psu.status()) # {"VOUT": 4.94, "IOUT": 0.012, "OUTPUT": "ON", ...}
68
+ print(psu.query("voltage")) # asks the instrument now
69
+ psu.set("output", "OFF")
70
+ ```
71
+
72
+ An instrument with several classes (the OWON SPM is a `PSU` and a `DMM`) needs the class:
73
+
74
+ ```python
75
+ spm = bench["owon-spm3103"]
76
+ spm.set("voltage", 3.3, cls="PSU")
77
+ spm.read("MEAS", cls="DMM")
78
+ ```
79
+
80
+ Multi-channel instruments take `ch=` (1-9, default 1): `scope.set("scale", 0.5, ch=2)`.
81
+
82
+ ## Values
83
+
84
+ - Measured values (SI values on the server) become `float` in base units (volts, amperes, ohms).
85
+ An instrument that reports something that is not a number (for example `OL` on an overloaded
86
+ meter) gives that text back.
87
+ - Modes and switches stay strings (`"ON"`, `"VOLT"`); flags stay `bool`; missing values are `None`.
88
+ - Binary results (screenshots) are `bytes`.
89
+ - `raw=True` on `query`, `read` and `status` returns the server's JSON unchanged.
90
+ - Setter values are sent as given: floats with `repr`, integers as integers, strings unchanged.
91
+ The server validates them against the definition (range, allowed values) and refuses invalid
92
+ ones with `BadRequest`. `inst.operations()` lists every query and setter with its value spec.
93
+
94
+ ## Examples
95
+
96
+ ### Read a meter, adjust a load
97
+
98
+ The classic loop: hold 5 V on a source by raising or lowering the load current
99
+ (full script: [examples/hold_voltage.py](examples/hold_voltage.py)).
100
+
101
+ ```python
102
+ import benchmesh
103
+
104
+ bench = benchmesh.connect()
105
+ load = bench["owon-oel1515"]
106
+ current = 0.0
107
+ load.set("mode", "CURR")
108
+ load.set("curr", current)
109
+ load.set("input", "ON") # the load starts drawing current
110
+ try:
111
+ for t, values in bench.watch({"v": ("owon-xdm1241", "DMM", "MEAS")}, interval=0.5, duration=60):
112
+ v = values["v"]
113
+ if v is None:
114
+ continue # meter offline
115
+ current = min(current + 0.01, 1.0) if v > 5.0 else max(current - 0.01, 0.0)
116
+ load.set("curr", round(current, 3))
117
+ finally:
118
+ load.set("input", "OFF")
119
+ ```
120
+
121
+ `bench.watch(signals)` polls the latest status (nothing extra is sent to the instruments) and
122
+ yields `(time_s, {label: value})` only when a reading was updated. A signal is
123
+ `(device_id, class, field)` or `(device_id, class, field, channel)`.
124
+
125
+ ### Sweep into pandas
126
+
127
+ The server records on whole seconds; the script only steps the voltage
128
+ (full script: [examples/sweep_to_pandas.py](examples/sweep_to_pandas.py)).
129
+
130
+ ```python
131
+ import time
132
+ import benchmesh
133
+
134
+ bench = benchmesh.connect()
135
+ psu = bench["tenma-72-2540"]
136
+ psu.set("voltage", 1.0)
137
+ psu.set("output", "ON")
138
+ try:
139
+ with bench.record(
140
+ "sweep", {"vout": (psu.id, "PSU", "VOUT"), "meter": ("owon-xdm1241", "DMM", "MEAS")}
141
+ ) as rec:
142
+ for v in (1.0, 2.0, 3.0, 4.0, 5.0):
143
+ psu.set("voltage", v)
144
+ time.sleep(2)
145
+ finally:
146
+ psu.set("output", "OFF")
147
+
148
+ df = rec.to_pandas() # index: time (UTC), one column per label
149
+ df.plot()
150
+ ```
151
+
152
+ Older recordings: `bench.recordings()` (newest first), `rec.data()` for plain lists
153
+ `{label: (times_s, values)}`, `rec.export_csv()` for the CSV the app exports.
154
+
155
+ ### Jupyter
156
+
157
+ ```python
158
+ import benchmesh
159
+
160
+ bench = benchmesh.connect()
161
+ rec = bench.recordings()[0]
162
+ df = rec.to_pandas()
163
+ df.resample("10s").mean().plot(subplots=True)
164
+ ```
165
+
166
+ ### Remote access
167
+
168
+ On the bench computer open the BenchMesh app, Instruments - Remote access: set a password,
169
+ switch it on and note the address and the certificate fingerprint. On the other computer:
170
+
171
+ ```python
172
+ bench = benchmesh.connect(
173
+ "https://192.168.1.20:57667",
174
+ password="the password",
175
+ fingerprint="AB:CD:...:EF", # as shown in the app; case and colons do not matter
176
+ )
177
+ ```
178
+
179
+ The certificate is self-signed, so the client pins it: right after the TLS handshake it compares
180
+ the certificate's SHA-256 fingerprint with the one you give, and only then sends the password.
181
+ A different certificate raises `FingerprintMismatch`. Without a fingerprint the system's
182
+ certificate authorities must trust it; otherwise `TLSError` tells you which fingerprint the
183
+ server presented, so you can compare it with the app. An expired session signs in again by
184
+ itself. `bench.close()` (or `with benchmesh.connect(...) as bench:`) signs out.
185
+
186
+ `BENCHMESH_URL`, `BENCHMESH_PASSWORD` and `BENCHMESH_FINGERPRINT` can replace the arguments
187
+ (see [examples/remote_watch.py](examples/remote_watch.py)).
188
+
189
+ ### Automation flows
190
+
191
+ ```python
192
+ for f in bench.flows():
193
+ print(f.id, f.name)
194
+
195
+ status = bench.run_flow("hold-5v", params={"target": 4.8}, arm=True, on_event=lambda e: print(e["msg"]))
196
+ print(status.state, status.vars) # "finished" / "failed" / "aborted"
197
+ ```
198
+
199
+ A flow that can switch outputs on needs `arm=True`, otherwise the server refuses it with
200
+ `Refused` (`error.body["energizes"]` lists the operations). `run_flow` waits until the run ends;
201
+ `timeout=` and Ctrl-C abort it (its cleanup still runs). A flow document (`dict`) can be run
202
+ directly instead of a saved id.
203
+
204
+ ## API overview
205
+
206
+ | | |
207
+ |---|---|
208
+ | `benchmesh.connect(url=None, *, password=None, fingerprint=None, timeout=30.0)` | `Bench`; checks the server answers |
209
+ | `bench.instruments(cls=None)` / `bench.instrument(id)` / `bench[id]` | `Instrument` |
210
+ | `bench.watch(signals, interval=0.5, duration=None)` | iterator of `(time_s, {label: value})` |
211
+ | `bench.record(name, signals, interval_s=1, description=None)` | `Recording` (context manager stops it) |
212
+ | `bench.recordings()` / `bench.recording(id)` | `Recording` |
213
+ | `bench.flows()` / `bench.flow(id)` / `bench.run_flow(flow, params=None, *, arm=False, wait=True, timeout=None, on_event=None)` | `Flow`, flow document, `RunStatus` |
214
+ | `bench.run_status()` / `bench.abort_run()` / `bench.continue_run()` | current run |
215
+ | `bench.version()` / `bench.close()` | |
216
+ | `inst.id`, `.name`, `.model`, `.idn`, `.classes`, `.channels`, `.online`, `.parked`, `.health` | facts from the server |
217
+ | `inst.set(op, value, *, cls=None, ch=1)` | run a setter |
218
+ | `inst.query(op, *, cls=None, ch=1, raw=False)` | run a query on the instrument |
219
+ | `inst.read(field, *, cls=None, ch=1, raw=False)` / `inst.status(...)` | latest polled status |
220
+ | `inst.operations(cls=None)` / `inst.features(cls=None)` / `inst.refresh()` | description |
221
+ | `rec.data(start=None, end=None, max_points=None)` / `rec.to_pandas(...)` / `rec.export_csv()` | samples |
222
+ | `rec.add(label, signal)` / `rec.pause()` / `rec.resume()` / `rec.stop()` / `rec.delete()` / `rec.refresh()` | control |
223
+
224
+ `cls` is omitted when the instrument has one class. Class names are three letters (`PSU`, `DMM`,
225
+ `ELL`, `AWG`, `OSC`, `LCR`, `SAL`), any case.
226
+
227
+ ### Errors
228
+
229
+ Every server error is a `BenchMeshError` with `.status`, `.detail` and `.body`:
230
+
231
+ | Exception | When |
232
+ |---|---|
233
+ | `ServerNotRunning` | nothing answers at the address: start the BenchMesh app |
234
+ | `BadRequest` (400, 422) | value outside the setter's range, invalid flow, ... |
235
+ | `AuthenticationError` (401, 403, sign-in) | wrong password, remote session not allowed |
236
+ | `NotFound` (404) | unknown instrument, class, operation, field, recording, flow |
237
+ | `Refused` (409) | parked instrument, a run is busy, flow needs `arm`, name taken |
238
+ | `InstrumentError` (502) | the instrument answered something unexpected |
239
+ | `InstrumentUnavailable` (503, 504) | not connected, parked or no answer in time |
240
+ | `TLSError`, `FingerprintMismatch` | remote certificate not trusted / not the pinned one |
241
+
242
+ Wrong arguments (class not on the instrument, channel 0, an empty name) raise `ValueError` or
243
+ `TypeError` before anything is sent.
244
+
245
+ ## Safety
246
+
247
+ The API acts at once. `set("output", "ON")` switches a supply output on, `set("input", "ON")` a
248
+ load input; there is no second confirmation as in the app. Check wiring and limits before you
249
+ run a script, keep `try` / `finally` blocks that switch outputs off, and test new scripts with
250
+ the simulated bench (`benchmesh-server --simulate`). Automation flows are safer for unattended
251
+ runs: they need `arm`, confirm every setting and switch off what they switched on when they fail.
252
+
253
+ ## Development
254
+
255
+ ```bash
256
+ uv sync # or: python -m venv .venv && pip install -e . pytest cryptography pandas
257
+ uv run pytest # unit tests (a fake server; no hardware)
258
+ uvx ruff check . && uvx ruff format --check .
259
+ ```
260
+
261
+ Integration tests run against a real server and are skipped unless `BENCHMESH_INTEGRATION_URL`
262
+ is set; use a simulated server:
263
+
264
+ ```bash
265
+ # in the BenchMesh repository
266
+ cargo build -p bm-server
267
+ BENCHMESH_ONLINE=off BENCHMESH_DATA_DIR=$(mktemp -d) ./target/debug/benchmesh-server \
268
+ --config captures/bench-config.yaml --simulate captures/20261016-203514 --port 57690
269
+ # here
270
+ BENCHMESH_INTEGRATION_URL=http://127.0.0.1:57690 uv run pytest tests/integration
271
+ # also remote access over HTTPS (simulation only; switches it on at that port and off again)
272
+ BENCHMESH_INTEGRATION_URL=http://127.0.0.1:57690 BENCHMESH_INTEGRATION_REMOTE_PORT=57697 uv run pytest tests/integration
273
+ ```
274
+
275
+ ### Releasing
276
+
277
+ Bump `src/benchmesh/_version.py`, commit, tag `vX.Y.Z` and push the tag. The `release`
278
+ workflow builds and publishes to PyPI with trusted publishing. Once, on PyPI: add a trusted
279
+ publisher for project `benchmesh` with this repository, workflow `release.yml` and
280
+ environment `pypi` (Account settings - Publishing - Add a new pending publisher).
281
+
282
+ ## Licence
283
+
284
+ MIT, see [LICENSE](LICENSE). BenchMesh: https://benchmesh.online
@@ -0,0 +1,253 @@
1
+ # benchmesh
2
+
3
+ Python client for [BenchMesh](https://benchmesh.online), one cockpit for lab instruments:
4
+ power supplies, multimeters, electronic loads, signal generators, oscilloscopes, LCR meters and
5
+ spectrum analyzers. Control them, read them and record them from scripts and Jupyter notebooks.
6
+
7
+ - No runtime dependencies (standard library only). Python 3.9 or newer.
8
+ - One API for every instrument of a class: the limits, ranges and modes come from the server.
9
+ - Live readings, recordings into pandas, automation flows, remote access with a pinned certificate.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install benchmesh # the client
15
+ pip install "benchmesh[pandas]" # plus pandas for DataFrames
16
+ ```
17
+
18
+ The BenchMesh app (or `benchmesh-server`) must be running. The client talks to its API at
19
+ `http://127.0.0.1:57666` on the same computer.
20
+
21
+ ## Quick start
22
+
23
+ ```python
24
+ import benchmesh
25
+
26
+ bench = benchmesh.connect() # $BENCHMESH_URL or http://127.0.0.1:57666
27
+
28
+ for inst in bench.instruments():
29
+ print(inst.id, inst.classes, inst.model, "online" if inst.online else "offline")
30
+
31
+ psu = bench["tenma-72-2540"] # the id from the Instruments drawer
32
+ psu.set("voltage", 5.0) # setter, checked by the server against 0-60 V
33
+ psu.set("current", 0.5)
34
+ psu.set("output", "ON") # switches the output ON at once
35
+ print(psu.read("VOUT")) # latest polled value: 4.94 (float, volts)
36
+ print(psu.status()) # {"VOUT": 4.94, "IOUT": 0.012, "OUTPUT": "ON", ...}
37
+ print(psu.query("voltage")) # asks the instrument now
38
+ psu.set("output", "OFF")
39
+ ```
40
+
41
+ An instrument with several classes (the OWON SPM is a `PSU` and a `DMM`) needs the class:
42
+
43
+ ```python
44
+ spm = bench["owon-spm3103"]
45
+ spm.set("voltage", 3.3, cls="PSU")
46
+ spm.read("MEAS", cls="DMM")
47
+ ```
48
+
49
+ Multi-channel instruments take `ch=` (1-9, default 1): `scope.set("scale", 0.5, ch=2)`.
50
+
51
+ ## Values
52
+
53
+ - Measured values (SI values on the server) become `float` in base units (volts, amperes, ohms).
54
+ An instrument that reports something that is not a number (for example `OL` on an overloaded
55
+ meter) gives that text back.
56
+ - Modes and switches stay strings (`"ON"`, `"VOLT"`); flags stay `bool`; missing values are `None`.
57
+ - Binary results (screenshots) are `bytes`.
58
+ - `raw=True` on `query`, `read` and `status` returns the server's JSON unchanged.
59
+ - Setter values are sent as given: floats with `repr`, integers as integers, strings unchanged.
60
+ The server validates them against the definition (range, allowed values) and refuses invalid
61
+ ones with `BadRequest`. `inst.operations()` lists every query and setter with its value spec.
62
+
63
+ ## Examples
64
+
65
+ ### Read a meter, adjust a load
66
+
67
+ The classic loop: hold 5 V on a source by raising or lowering the load current
68
+ (full script: [examples/hold_voltage.py](examples/hold_voltage.py)).
69
+
70
+ ```python
71
+ import benchmesh
72
+
73
+ bench = benchmesh.connect()
74
+ load = bench["owon-oel1515"]
75
+ current = 0.0
76
+ load.set("mode", "CURR")
77
+ load.set("curr", current)
78
+ load.set("input", "ON") # the load starts drawing current
79
+ try:
80
+ for t, values in bench.watch({"v": ("owon-xdm1241", "DMM", "MEAS")}, interval=0.5, duration=60):
81
+ v = values["v"]
82
+ if v is None:
83
+ continue # meter offline
84
+ current = min(current + 0.01, 1.0) if v > 5.0 else max(current - 0.01, 0.0)
85
+ load.set("curr", round(current, 3))
86
+ finally:
87
+ load.set("input", "OFF")
88
+ ```
89
+
90
+ `bench.watch(signals)` polls the latest status (nothing extra is sent to the instruments) and
91
+ yields `(time_s, {label: value})` only when a reading was updated. A signal is
92
+ `(device_id, class, field)` or `(device_id, class, field, channel)`.
93
+
94
+ ### Sweep into pandas
95
+
96
+ The server records on whole seconds; the script only steps the voltage
97
+ (full script: [examples/sweep_to_pandas.py](examples/sweep_to_pandas.py)).
98
+
99
+ ```python
100
+ import time
101
+ import benchmesh
102
+
103
+ bench = benchmesh.connect()
104
+ psu = bench["tenma-72-2540"]
105
+ psu.set("voltage", 1.0)
106
+ psu.set("output", "ON")
107
+ try:
108
+ with bench.record(
109
+ "sweep", {"vout": (psu.id, "PSU", "VOUT"), "meter": ("owon-xdm1241", "DMM", "MEAS")}
110
+ ) as rec:
111
+ for v in (1.0, 2.0, 3.0, 4.0, 5.0):
112
+ psu.set("voltage", v)
113
+ time.sleep(2)
114
+ finally:
115
+ psu.set("output", "OFF")
116
+
117
+ df = rec.to_pandas() # index: time (UTC), one column per label
118
+ df.plot()
119
+ ```
120
+
121
+ Older recordings: `bench.recordings()` (newest first), `rec.data()` for plain lists
122
+ `{label: (times_s, values)}`, `rec.export_csv()` for the CSV the app exports.
123
+
124
+ ### Jupyter
125
+
126
+ ```python
127
+ import benchmesh
128
+
129
+ bench = benchmesh.connect()
130
+ rec = bench.recordings()[0]
131
+ df = rec.to_pandas()
132
+ df.resample("10s").mean().plot(subplots=True)
133
+ ```
134
+
135
+ ### Remote access
136
+
137
+ On the bench computer open the BenchMesh app, Instruments - Remote access: set a password,
138
+ switch it on and note the address and the certificate fingerprint. On the other computer:
139
+
140
+ ```python
141
+ bench = benchmesh.connect(
142
+ "https://192.168.1.20:57667",
143
+ password="the password",
144
+ fingerprint="AB:CD:...:EF", # as shown in the app; case and colons do not matter
145
+ )
146
+ ```
147
+
148
+ The certificate is self-signed, so the client pins it: right after the TLS handshake it compares
149
+ the certificate's SHA-256 fingerprint with the one you give, and only then sends the password.
150
+ A different certificate raises `FingerprintMismatch`. Without a fingerprint the system's
151
+ certificate authorities must trust it; otherwise `TLSError` tells you which fingerprint the
152
+ server presented, so you can compare it with the app. An expired session signs in again by
153
+ itself. `bench.close()` (or `with benchmesh.connect(...) as bench:`) signs out.
154
+
155
+ `BENCHMESH_URL`, `BENCHMESH_PASSWORD` and `BENCHMESH_FINGERPRINT` can replace the arguments
156
+ (see [examples/remote_watch.py](examples/remote_watch.py)).
157
+
158
+ ### Automation flows
159
+
160
+ ```python
161
+ for f in bench.flows():
162
+ print(f.id, f.name)
163
+
164
+ status = bench.run_flow("hold-5v", params={"target": 4.8}, arm=True, on_event=lambda e: print(e["msg"]))
165
+ print(status.state, status.vars) # "finished" / "failed" / "aborted"
166
+ ```
167
+
168
+ A flow that can switch outputs on needs `arm=True`, otherwise the server refuses it with
169
+ `Refused` (`error.body["energizes"]` lists the operations). `run_flow` waits until the run ends;
170
+ `timeout=` and Ctrl-C abort it (its cleanup still runs). A flow document (`dict`) can be run
171
+ directly instead of a saved id.
172
+
173
+ ## API overview
174
+
175
+ | | |
176
+ |---|---|
177
+ | `benchmesh.connect(url=None, *, password=None, fingerprint=None, timeout=30.0)` | `Bench`; checks the server answers |
178
+ | `bench.instruments(cls=None)` / `bench.instrument(id)` / `bench[id]` | `Instrument` |
179
+ | `bench.watch(signals, interval=0.5, duration=None)` | iterator of `(time_s, {label: value})` |
180
+ | `bench.record(name, signals, interval_s=1, description=None)` | `Recording` (context manager stops it) |
181
+ | `bench.recordings()` / `bench.recording(id)` | `Recording` |
182
+ | `bench.flows()` / `bench.flow(id)` / `bench.run_flow(flow, params=None, *, arm=False, wait=True, timeout=None, on_event=None)` | `Flow`, flow document, `RunStatus` |
183
+ | `bench.run_status()` / `bench.abort_run()` / `bench.continue_run()` | current run |
184
+ | `bench.version()` / `bench.close()` | |
185
+ | `inst.id`, `.name`, `.model`, `.idn`, `.classes`, `.channels`, `.online`, `.parked`, `.health` | facts from the server |
186
+ | `inst.set(op, value, *, cls=None, ch=1)` | run a setter |
187
+ | `inst.query(op, *, cls=None, ch=1, raw=False)` | run a query on the instrument |
188
+ | `inst.read(field, *, cls=None, ch=1, raw=False)` / `inst.status(...)` | latest polled status |
189
+ | `inst.operations(cls=None)` / `inst.features(cls=None)` / `inst.refresh()` | description |
190
+ | `rec.data(start=None, end=None, max_points=None)` / `rec.to_pandas(...)` / `rec.export_csv()` | samples |
191
+ | `rec.add(label, signal)` / `rec.pause()` / `rec.resume()` / `rec.stop()` / `rec.delete()` / `rec.refresh()` | control |
192
+
193
+ `cls` is omitted when the instrument has one class. Class names are three letters (`PSU`, `DMM`,
194
+ `ELL`, `AWG`, `OSC`, `LCR`, `SAL`), any case.
195
+
196
+ ### Errors
197
+
198
+ Every server error is a `BenchMeshError` with `.status`, `.detail` and `.body`:
199
+
200
+ | Exception | When |
201
+ |---|---|
202
+ | `ServerNotRunning` | nothing answers at the address: start the BenchMesh app |
203
+ | `BadRequest` (400, 422) | value outside the setter's range, invalid flow, ... |
204
+ | `AuthenticationError` (401, 403, sign-in) | wrong password, remote session not allowed |
205
+ | `NotFound` (404) | unknown instrument, class, operation, field, recording, flow |
206
+ | `Refused` (409) | parked instrument, a run is busy, flow needs `arm`, name taken |
207
+ | `InstrumentError` (502) | the instrument answered something unexpected |
208
+ | `InstrumentUnavailable` (503, 504) | not connected, parked or no answer in time |
209
+ | `TLSError`, `FingerprintMismatch` | remote certificate not trusted / not the pinned one |
210
+
211
+ Wrong arguments (class not on the instrument, channel 0, an empty name) raise `ValueError` or
212
+ `TypeError` before anything is sent.
213
+
214
+ ## Safety
215
+
216
+ The API acts at once. `set("output", "ON")` switches a supply output on, `set("input", "ON")` a
217
+ load input; there is no second confirmation as in the app. Check wiring and limits before you
218
+ run a script, keep `try` / `finally` blocks that switch outputs off, and test new scripts with
219
+ the simulated bench (`benchmesh-server --simulate`). Automation flows are safer for unattended
220
+ runs: they need `arm`, confirm every setting and switch off what they switched on when they fail.
221
+
222
+ ## Development
223
+
224
+ ```bash
225
+ uv sync # or: python -m venv .venv && pip install -e . pytest cryptography pandas
226
+ uv run pytest # unit tests (a fake server; no hardware)
227
+ uvx ruff check . && uvx ruff format --check .
228
+ ```
229
+
230
+ Integration tests run against a real server and are skipped unless `BENCHMESH_INTEGRATION_URL`
231
+ is set; use a simulated server:
232
+
233
+ ```bash
234
+ # in the BenchMesh repository
235
+ cargo build -p bm-server
236
+ BENCHMESH_ONLINE=off BENCHMESH_DATA_DIR=$(mktemp -d) ./target/debug/benchmesh-server \
237
+ --config captures/bench-config.yaml --simulate captures/20261016-203514 --port 57690
238
+ # here
239
+ BENCHMESH_INTEGRATION_URL=http://127.0.0.1:57690 uv run pytest tests/integration
240
+ # also remote access over HTTPS (simulation only; switches it on at that port and off again)
241
+ BENCHMESH_INTEGRATION_URL=http://127.0.0.1:57690 BENCHMESH_INTEGRATION_REMOTE_PORT=57697 uv run pytest tests/integration
242
+ ```
243
+
244
+ ### Releasing
245
+
246
+ Bump `src/benchmesh/_version.py`, commit, tag `vX.Y.Z` and push the tag. The `release`
247
+ workflow builds and publishes to PyPI with trusted publishing. Once, on PyPI: add a trusted
248
+ publisher for project `benchmesh` with this repository, workflow `release.yml` and
249
+ environment `pypi` (Account settings - Publishing - Add a new pending publisher).
250
+
251
+ ## Licence
252
+
253
+ MIT, see [LICENSE](LICENSE). BenchMesh: https://benchmesh.online
@@ -0,0 +1,55 @@
1
+ """Hold a voltage with an electronic load: read the DMM, raise or lower the load current.
2
+
3
+ The classic loop: a source (battery, supply, converter) feeds an electronic load, a meter
4
+ measures the voltage. While the meter reads above the target the load draws more current,
5
+ below it draws less. The load input is switched off at the end, also on Ctrl-C or an error.
6
+
7
+ python examples/hold_voltage.py --target 5.0 --dmm owon-xdm1241 --load owon-oel1515
8
+
9
+ Safety: this switches the load input on. Check the wiring and the limits first.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+
16
+ import benchmesh
17
+
18
+
19
+ def main() -> None:
20
+ p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
21
+ p.add_argument("--url", default=None, help="server address (default: this computer)")
22
+ p.add_argument("--dmm", default="owon-xdm1241", help="meter id")
23
+ p.add_argument("--load", default="owon-oel1515", help="electronic load id")
24
+ p.add_argument("--target", type=float, default=5.0, help="voltage to hold (V)")
25
+ p.add_argument("--step", type=float, default=0.01, help="current step per reading (A)")
26
+ p.add_argument("--max-current", type=float, default=1.0, help="never draw more than this (A)")
27
+ p.add_argument("--seconds", type=float, default=60, help="how long to regulate")
28
+ args = p.parse_args()
29
+
30
+ bench = benchmesh.connect(args.url)
31
+ load = bench[args.load]
32
+ current = 0.0
33
+ load.set("mode", "CURR")
34
+ load.set("curr", current)
35
+ load.set("input", "ON") # energizes: the load starts drawing current
36
+ try:
37
+ signals = {"v": (args.dmm, "DMM", "MEAS")}
38
+ for _, values in bench.watch(signals, interval=0.5, duration=args.seconds):
39
+ v = values["v"]
40
+ if not isinstance(v, float):
41
+ print("no reading from the meter")
42
+ continue
43
+ if v > args.target:
44
+ current = min(current + args.step, args.max_current)
45
+ elif v < args.target:
46
+ current = max(current - args.step, 0.0)
47
+ load.set("curr", round(current, 3))
48
+ print(f"{v:8.4f} V -> load {current:6.3f} A")
49
+ finally:
50
+ load.set("input", "OFF")
51
+ print("load input off")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,46 @@
1
+ """Watch the readings of a bench on another computer through remote access.
2
+
3
+ On the bench computer: BenchMesh app, Instruments - Remote access: set a password, switch it
4
+ on and note the address and the certificate fingerprint. Then here:
5
+
6
+ export BENCHMESH_URL=https://192.168.1.20:57667
7
+ export BENCHMESH_PASSWORD='the password'
8
+ export BENCHMESH_FINGERPRINT=AB:CD:... # as shown in the app
9
+ python examples/remote_watch.py
10
+
11
+ Every status field of every online instrument is printed when it changes. Read-only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import benchmesh
17
+
18
+
19
+ def main() -> None:
20
+ with benchmesh.connect() as bench: # signs out at the end
21
+ signals = {}
22
+ for inst in bench.instruments():
23
+ if not inst.online:
24
+ continue
25
+ for cls in inst.classes:
26
+ try:
27
+ status = inst.status(cls=cls)
28
+ except benchmesh.InstrumentUnavailable:
29
+ continue # not polled yet
30
+ for field, value in status.items():
31
+ if isinstance(value, float):
32
+ signals[f"{inst.id} {cls} {field}"] = (inst.id, cls, field)
33
+ if not signals:
34
+ print("no online instrument with numeric readings")
35
+ return
36
+ try:
37
+ for t, values in bench.watch(signals, interval=1.0):
38
+ print(f"--- {t:.1f}")
39
+ for label, value in values.items():
40
+ print(f"{label:40s} {value}")
41
+ except KeyboardInterrupt:
42
+ pass
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()