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.
- benchmesh-0.1.0/.gitignore +10 -0
- benchmesh-0.1.0/LICENSE +21 -0
- benchmesh-0.1.0/PKG-INFO +284 -0
- benchmesh-0.1.0/README.md +253 -0
- benchmesh-0.1.0/examples/hold_voltage.py +55 -0
- benchmesh-0.1.0/examples/remote_watch.py +46 -0
- benchmesh-0.1.0/examples/sweep_to_pandas.py +58 -0
- benchmesh-0.1.0/pyproject.toml +74 -0
- benchmesh-0.1.0/src/benchmesh/__init__.py +53 -0
- benchmesh-0.1.0/src/benchmesh/_validate.py +42 -0
- benchmesh-0.1.0/src/benchmesh/_version.py +1 -0
- benchmesh-0.1.0/src/benchmesh/bench.py +203 -0
- benchmesh-0.1.0/src/benchmesh/errors.py +75 -0
- benchmesh-0.1.0/src/benchmesh/flows.py +173 -0
- benchmesh-0.1.0/src/benchmesh/instrument.py +225 -0
- benchmesh-0.1.0/src/benchmesh/py.typed +0 -0
- benchmesh-0.1.0/src/benchmesh/recordings.py +270 -0
- benchmesh-0.1.0/src/benchmesh/signals.py +48 -0
- benchmesh-0.1.0/src/benchmesh/transport.py +341 -0
- benchmesh-0.1.0/src/benchmesh/values.py +103 -0
- benchmesh-0.1.0/src/benchmesh/watch.py +75 -0
- benchmesh-0.1.0/tests/conftest.py +234 -0
- benchmesh-0.1.0/tests/fake_server.py +130 -0
- benchmesh-0.1.0/tests/integration/test_live.py +201 -0
- benchmesh-0.1.0/tests/test_flows.py +168 -0
- benchmesh-0.1.0/tests/test_instruments.py +245 -0
- benchmesh-0.1.0/tests/test_no_dashes.py +24 -0
- benchmesh-0.1.0/tests/test_recordings.py +239 -0
- benchmesh-0.1.0/tests/test_remote.py +191 -0
- benchmesh-0.1.0/tests/test_values.py +88 -0
- benchmesh-0.1.0/tests/test_watch.py +56 -0
benchmesh-0.1.0/LICENSE
ADDED
|
@@ -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.
|
benchmesh-0.1.0/PKG-INFO
ADDED
|
@@ -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()
|