firmwareloop 0.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ instrument_cli.py - VISA/SCPI instrument layer (Spec §14-17, M5).
4
+
5
+ Measurements over PyVISA -> VISA/SCPI, or the built-in simulator backend.
6
+ Every command returns one JSON document; every *write* is validated against
7
+ lab/limits.yaml (Spec §17) and refused with SAFETY_LIMIT when out of bounds.
8
+
9
+ Commands (Spec §15):
10
+ instrument list
11
+ instrument idn --instrument <name>
12
+ scope measure-frequency --instrument <name> --channel CH1
13
+ scope measure-duty | measure-vpp | measure-rms | measure-rise-time
14
+ scope capture-waveform --instrument <name> --channel CH1 --output <file.csv>
15
+ psu measure-voltage | measure-current | measure-power
16
+ psu output --state on|off [--voltage V] [--current A]
17
+ dmm measure-voltage | measure-resistance
18
+ relay --name <name> --state on|off
19
+
20
+ raw_scpi() is deliberately NOT implemented (Spec §15: default forbidden).
21
+
22
+ Exit codes: 0 ok, 1 measurement/execution failure, 2 config/safety denial.
23
+
24
+ Non-hardware verification: --backend simulator
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import sys
33
+ import time
34
+
35
+ # GAP-006: the thin instrument layer (open/identify/query/normalize/timeout/
36
+ # close/error mapping) lives in tools/lib/instruments.py; this CLI is a thin
37
+ # command surface on top of it.
38
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
39
+ from tools.lib import instruments as libinstr # noqa: E402
40
+
41
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
42
+
43
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
44
+ DEFAULT_CONFIG = os.path.join(REPO_ROOT, "lab", "lab.yaml")
45
+ FALLBACK_CONFIG = os.path.join(REPO_ROOT, "lab", "lab.example.yaml")
46
+ LIMITS_PATH = os.path.join(REPO_ROOT, "lab", "limits.yaml")
47
+
48
+ try:
49
+ import yaml
50
+ except ImportError: # pragma: no cover
51
+ yaml = None
52
+
53
+
54
+ # --------------------------------------------------------------------------- errors
55
+ def fail(error_class: str, message: str, detail=None) -> None:
56
+ body = {"ok": False, "error_class": error_class, "error": message}
57
+ if detail:
58
+ body["detail"] = detail
59
+ print(json.dumps(body, ensure_ascii=False))
60
+ sys.exit(1)
61
+
62
+
63
+ def ok(payload: dict) -> None:
64
+ print(json.dumps(payload, ensure_ascii=False))
65
+ sys.exit(0)
66
+
67
+
68
+ def _now() -> str:
69
+ return time.strftime("%Y-%m-%dT%H:%M:%S%z")
70
+
71
+
72
+ # --------------------------------------------------------------------------- config
73
+ def load_config() -> dict:
74
+ for cand in (os.environ.get("FW_LAB_CONFIG"), DEFAULT_CONFIG, FALLBACK_CONFIG):
75
+ if cand and os.path.isfile(cand):
76
+ try:
77
+ if yaml is not None:
78
+ with open(cand, encoding="utf-8") as fh:
79
+ return yaml.safe_load(fh) or {}
80
+ return json.load(open(cand, encoding="utf-8"))
81
+ except Exception as exc: # noqa: BLE001
82
+ fail("CONFIG_ERROR", f"cannot parse lab config {cand}: {exc}")
83
+ return {}
84
+
85
+
86
+ def _authoritative_limits_path() -> str | None:
87
+ """Locate the trusted safety policy (GAP-009 v0.0.2):
88
+ 1. FIRMWARELOOP_BENCH_CONFIG (trusted absolute path, file or dir)
89
+ 2. %APPDATA%\\FirmwareLoop\\benches\\<bench-id>\\limits.yaml
90
+ Never the repo example - a workspace edit must not be privilege escalation.
91
+ """
92
+ env = os.environ.get("FIRMWARELOOP_BENCH_CONFIG")
93
+ if env:
94
+ if os.path.isfile(env):
95
+ return env
96
+ cand = os.path.join(env, "limits.yaml")
97
+ if os.path.isfile(cand):
98
+ return cand
99
+ return None
100
+ bench_id = os.environ.get("FIRMWARELOOP_BENCH_ID")
101
+ if bench_id:
102
+ cand = os.path.join(os.environ.get("APPDATA", ""), "FirmwareLoop", "benches", bench_id, "limits.yaml")
103
+ if os.path.isfile(cand):
104
+ return cand
105
+ return None
106
+
107
+
108
+ def load_limits(require_authoritative: bool = False) -> dict:
109
+ """Load limits. For real hardware writes the authoritative policy is
110
+ mandatory (fail closed): missing trusted config => CONFIG_ERROR.
111
+ Read-only measurements may fall back to the repo example (no write risk)."""
112
+ trusted = _authoritative_limits_path()
113
+ if trusted:
114
+ try:
115
+ if yaml is not None:
116
+ with open(trusted, encoding="utf-8") as fh:
117
+ data = yaml.safe_load(fh) or {}
118
+ else:
119
+ data = json.load(open(trusted, encoding="utf-8"))
120
+ data["_source"] = trusted
121
+ return data
122
+ except Exception as exc: # noqa: BLE001
123
+ fail("CONFIG_ERROR", f"cannot parse authoritative limits {trusted}: {exc}")
124
+ if require_authoritative:
125
+ fail("CONFIG_ERROR",
126
+ "no authoritative safety policy found for a real hardware write",
127
+ detail="set FIRMWARELOOP_BENCH_CONFIG=<trusted limits.yaml or bench dir>, or create "
128
+ "%APPDATA%\\FirmwareLoop\\benches\\<bench-id>\\limits.yaml (GAP-009). "
129
+ "Fail closed: the repo example is never authoritative.")
130
+ # read-only fallback to the repo example (never for writes)
131
+ example = os.path.join(REPO_ROOT, "lab", "limits.example.yaml")
132
+ if os.path.isfile(example):
133
+ try:
134
+ if yaml is not None:
135
+ with open(example, encoding="utf-8") as fh:
136
+ data = yaml.safe_load(fh) or {}
137
+ else:
138
+ data = json.load(open(example, encoding="utf-8"))
139
+ data["_source"] = example
140
+ data["_authoritative"] = False
141
+ return data
142
+ except Exception as exc: # noqa: BLE001
143
+ fail("CONFIG_ERROR", f"cannot parse {example}: {exc}")
144
+ fail("CONFIG_ERROR", "missing safety limits: no authoritative config and no lab/limits.example.yaml")
145
+
146
+
147
+ def get_instrument(config: dict, name: str) -> dict:
148
+ inst = (config.get("instruments") or {}).get(name)
149
+ if not inst:
150
+ fail("INSTRUMENT_NOT_FOUND", f"instrument '{name}' is not defined in lab config",
151
+ detail="available: " + ", ".join(sorted(config.get("instruments") or {})))
152
+ return dict(inst)
153
+
154
+
155
+ # --------------------------------------------------------------------------- clients
156
+ # GAP-006: clients (visa/simulator) are provided by tools/lib/instruments.py;
157
+ # the CLI only maps commands to them.
158
+
159
+ # Default SCPI recipes; override per instrument in lab config (instrument.commands.*)
160
+ SCOPE_COMMANDS = {
161
+ "idn": "*IDN?",
162
+ "measure_frequency": ":MEASure:FREQuency? {ch}",
163
+ "measure_duty": ":MEASure:PDUTy? {ch}",
164
+ "measure_vpp": ":MEASure:VPP? {ch}",
165
+ "measure_rms": ":MEASure:VRMS? {ch}",
166
+ "measure_rise_time": ":MEASure:RTIMe? {ch}",
167
+ "waveform": ":WAVeform:DATA? {ch}",
168
+ }
169
+ PSU_COMMANDS = {"idn": "*IDN?", "meas_voltage": "MEAS:VOLT?", "meas_current": "MEAS:CURR?", "meas_power": "MEAS:POW?"}
170
+ DMM_COMMANDS = {"idn": "*IDN?", "meas_voltage": "MEAS:VOLT:DC?", "meas_resistance": "MEAS:RES?"}
171
+
172
+
173
+ def _cmd(inst: dict, kind: str, key: str, default: str) -> str:
174
+ cmds = inst.get("commands") or {}
175
+ return cmds.get(f"{kind}.{key}", cmds.get(key, default))
176
+
177
+
178
+ def _measure(config, args, kind: str, key: str, unit: str, channel_key: str = None) -> None:
179
+ inst = get_instrument(config, args.instrument)
180
+ chan = args.channel if channel_key else None
181
+ backend = inst.get("backend", "simulator")
182
+ sim = config.get("simulated_measurements") or {}
183
+ ts = _now()
184
+ table = sim.get(kind, {})
185
+
186
+ try:
187
+ client = libinstr.open_instrument(inst, table=table, timeout_ms=int(inst.get("timeout_ms", 5000)))
188
+ try:
189
+ scpi = _cmd(inst, kind, key, _default_scpi(kind, key)).format(ch=chan or "")
190
+ if backend == "visa":
191
+ val = libinstr.query_measurement(client, scpi)
192
+ result = {"schema": "lab-measurement/v1", "ok": True, "instrument": args.instrument,
193
+ "measurement": key, "channel": chan, "value": val, "unit": unit, "timestamp": ts,
194
+ "execution_mode": "real", "simulated": False, "hardware_validated": True}
195
+ else:
196
+ val = float(client.query(scpi))
197
+ result = {"schema": "lab-measurement/v1", "ok": True, "instrument": args.instrument,
198
+ "measurement": key, "channel": chan, "value": val, "unit": unit, "timestamp": ts,
199
+ "execution_mode": "simulator", "simulated": True, "hardware_validated": False,
200
+ "backend": "simulator"}
201
+ finally:
202
+ libinstr.close(client)
203
+ except libinstr.InstrumentError as exc:
204
+ fail(exc.error_class, exc.message, exc.detail)
205
+ ok(result)
206
+
207
+
208
+ def _default_scpi(kind: str, key: str) -> str:
209
+ # normalized measurement key -> default SCPI recipe (device profiles may
210
+ # override per instrument via lab config commands.*)
211
+ recipes = {
212
+ "frequency": ":MEASure:FREQuency? {ch}",
213
+ "duty": ":MEASure:PDUTy? {ch}",
214
+ "vpp": ":MEASure:VPP? {ch}",
215
+ "rms": ":MEASure:VRMS? {ch}",
216
+ "rise_time": ":MEASure:RTIMe? {ch}",
217
+ "voltage": "MEAS:VOLT?",
218
+ "current": "MEAS:CURR?",
219
+ "power": "MEAS:POW?",
220
+ "resistance": "MEAS:RES?",
221
+ }
222
+ return recipes.get(key, f"MEAS:{key.upper()}?")
223
+
224
+
225
+ # --------------------------------------------------------------------------- safety
226
+ def check_limit(limits: dict, category: str, name: str, key: str, value) -> float:
227
+ """Return the capped/validated value or abort with SAFETY_LIMIT."""
228
+ limits = dict(limits) # copy: never mutate the loaded policy
229
+ if limits.get("ai_may_edit"):
230
+ fail("PERMISSION_DENIED", "ai_may_edit must not be set in the shipped limits.yaml (Spec §17)")
231
+ cat = limits.get(category) or {}
232
+ entry = cat.get(name) or {}
233
+ maxv = entry.get(key)
234
+ if maxv is not None and value > maxv:
235
+ fail("SAFETY_LIMIT", f"{category}.{name}.{key} would exceed configured maximum {maxv} (requested {value})",
236
+ detail=f"check lab/limits.yaml")
237
+ return value
238
+
239
+
240
+ def do_psu_output(config, args, limits) -> None:
241
+ inst = get_instrument(config, args.instrument)
242
+ backend = inst.get("backend", "simulator")
243
+ allowed = (limits.get("power") or {}).get(args.instrument, {})
244
+ if not allowed.get("allow_output_toggle", False):
245
+ fail("SAFETY_LIMIT", f"output toggle on '{args.instrument}' is not allowed by lab/limits.yaml")
246
+
247
+ state = args.state.lower()
248
+ if state == "on":
249
+ if args.voltage is not None:
250
+ check_limit(limits, "power", args.instrument, "max_voltage_v", float(args.voltage))
251
+ if args.current is not None:
252
+ check_limit(limits, "power", args.instrument, "max_current_a", float(args.current))
253
+
254
+ if backend == "visa":
255
+ try:
256
+ client = libinstr.open_instrument(inst, timeout_ms=int(inst.get("timeout_ms", 5000)))
257
+ try:
258
+ if state == "on":
259
+ if args.voltage is not None:
260
+ client.write(f"APPL {args.voltage}")
261
+ if args.current is not None:
262
+ client.write(f"CURR {args.current}")
263
+ client.write(f"OUTP {1 if state == 'on' else 0}")
264
+ finally:
265
+ libinstr.close(client)
266
+ except libinstr.InstrumentError as exc:
267
+ fail(exc.error_class, exc.message, exc.detail)
268
+ ok({"schema": "lab-measurement/v1", "ok": True, "instrument": args.instrument, "measurement": "output",
269
+ "state": state, "voltage_v": args.voltage, "current_a": args.current,
270
+ "channel": "OUT1", "unit": "bool", "timestamp": _now(), "backend": backend})
271
+
272
+
273
+ def do_relay(config, args, limits) -> None:
274
+ allowed = limits.get("relay", {}).get("allowed", [])
275
+ if args.name not in allowed:
276
+ fail("SAFETY_LIMIT", f"relay '{args.name}' is not in lab/limits.yaml relay.allowed whitelist",
277
+ detail=f"allowed: {allowed}")
278
+ if args.state.lower() not in ("on", "off"):
279
+ fail("CONFIG_ERROR", f"relay state must be on|off, got '{args.state}'")
280
+ inst = get_instrument(config, args.instrument)
281
+ backend = inst.get("backend", "simulator")
282
+ if backend == "visa":
283
+ ch = inst.get("relay_channel", args.name.upper())
284
+ try:
285
+ client = libinstr.open_instrument(inst, timeout_ms=int(inst.get("timeout_ms", 5000)))
286
+ try:
287
+ client.write(f"OUTP:CH{ch} {1 if args.state.lower() == 'on' else 0}")
288
+ finally:
289
+ libinstr.close(client)
290
+ except libinstr.InstrumentError as exc:
291
+ fail(exc.error_class, exc.message, exc.detail)
292
+ ok({"schema": "lab-measurement/v1", "ok": True, "instrument": args.instrument, "measurement": "relay",
293
+ "relay": args.name, "state": args.state.lower(), "timestamp": _now(), "backend": backend})
294
+
295
+
296
+ # --------------------------------------------------------------------------- main
297
+ def main() -> None:
298
+ parser = argparse.ArgumentParser(prog="instrument_cli.py")
299
+ sub = parser.add_subparsers(dest="command", required=True)
300
+
301
+ p_list = sub.add_parser("list", help="list configured instruments")
302
+ p_list.add_argument("--config", default=DEFAULT_CONFIG)
303
+
304
+ p_idn = sub.add_parser("idn", help="read instrument identification")
305
+ p_idn.add_argument("--instrument", required=True)
306
+
307
+ p_scope = sub.add_parser("scope", help="scope measurements")
308
+ scope_sub = p_scope.add_subparsers(dest="scope_cmd", required=True)
309
+ for meas in ("measure-frequency", "measure-duty", "measure-vpp", "measure-rms", "measure-rise-time"):
310
+ pm = scope_sub.add_parser(meas)
311
+ pm.add_argument("--instrument", required=True)
312
+ pm.add_argument("--channel", default="CH1")
313
+ pw = scope_sub.add_parser("capture-waveform")
314
+ pw.add_argument("--instrument", required=True)
315
+ pw.add_argument("--channel", default="CH1")
316
+ pw.add_argument("--output", required=True)
317
+
318
+ p_psu = sub.add_parser("psu", help="power supply measurements/control")
319
+ psu_sub = p_psu.add_subparsers(dest="psu_cmd", required=True)
320
+ for meas in ("measure-voltage", "measure-current", "measure-power"):
321
+ pm = psu_sub.add_parser(meas)
322
+ pm.add_argument("--instrument", required=True)
323
+ po = psu_sub.add_parser("output")
324
+ po.add_argument("--instrument", required=True)
325
+ po.add_argument("--state", required=True, choices=["on", "off"])
326
+ po.add_argument("--voltage", type=float)
327
+ po.add_argument("--current", type=float)
328
+
329
+ p_dmm = sub.add_parser("dmm", help="multimeter measurements")
330
+ dmm_sub = p_dmm.add_subparsers(dest="dmm_cmd", required=True)
331
+ for meas in ("measure-voltage", "measure-resistance"):
332
+ pm = dmm_sub.add_parser(meas)
333
+ pm.add_argument("--instrument", required=True)
334
+
335
+ p_relay = sub.add_parser("relay", help="relay control (whitelisted names only)")
336
+ p_relay.add_argument("--instrument", required=True)
337
+ p_relay.add_argument("--name", required=True)
338
+ p_relay.add_argument("--state", required=True, choices=["on", "off"])
339
+
340
+ args, _ = parser.parse_known_args()
341
+ config = load_config()
342
+ # writes (psu output / relay) demand the authoritative policy: fail closed
343
+ requires_policy = (args.command == "relay") or (
344
+ args.command == "psu" and getattr(args, "psu_cmd", None) == "output"
345
+ )
346
+ limits = load_limits(require_authoritative=requires_policy)
347
+
348
+ if args.command == "list":
349
+ insts = config.get("instruments") or {}
350
+ rows = [{"name": k, "type": (v or {}).get("type"), "backend": (v or {}).get("backend", "simulator")}
351
+ for k, v in sorted(insts.items())]
352
+ ok({"schema": "lab-instrument-list/v1", "ok": True, "instruments": rows, "timestamp": _now()})
353
+
354
+ if args.command == "idn":
355
+ inst = get_instrument(config, args.instrument)
356
+ key = "idn"
357
+ backend = inst.get("backend", "simulator")
358
+ if backend == "visa":
359
+ try:
360
+ client = libinstr.open_instrument(inst, timeout_ms=int(inst.get("timeout_ms", 5000)))
361
+ try:
362
+ val = libinstr.identify(client)
363
+ finally:
364
+ libinstr.close(client)
365
+ except libinstr.InstrumentError as exc:
366
+ fail(exc.error_class, exc.message, exc.detail)
367
+ else:
368
+ val = f"simulated {args.instrument} (backend=simulator)"
369
+ ok({"schema": "lab-measurement/v1", "ok": True, "instrument": args.instrument,
370
+ "measurement": "idn", "value": val, "unit": None, "timestamp": _now(), "backend": backend})
371
+
372
+ if args.command == "scope":
373
+ m = args.scope_cmd.replace("-", "_")
374
+ if m == "capture_waveform":
375
+ inst = get_instrument(config, args.instrument)
376
+ backend = inst.get("backend", "simulator")
377
+ out = args.output
378
+ os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
379
+ if backend == "visa":
380
+ # GAP-007: a real backend must never return synthesized data.
381
+ # Waveform capture requires a device-specific profile (based on
382
+ # the vendor Programming Manual); none is implemented yet, so
383
+ # the honest answer is CAPABILITY_NOT_SUPPORTED - never fake ok.
384
+ fail("CAPABILITY_NOT_SUPPORTED",
385
+ "waveform capture is not supported for backend=visa without an instrument profile",
386
+ detail="define a waveform profile from the vendor Programming Manual, or use backend=simulator for dry runs")
387
+ with open(out, "w", encoding="utf-8") as fh:
388
+ fh.write("time_s,value_v\n0.0,0.0\n0.00001,3.3\n")
389
+ ok({"schema": "lab-measurement/v1", "ok": True, "instrument": args.instrument,
390
+ "measurement": "waveform", "channel": args.channel, "value": None, "unit": "csv",
391
+ "capture": os.path.abspath(out), "simulated": True,
392
+ "hardware_validated": False, "timestamp": _now(), "backend": backend})
393
+ key = {"measure_frequency": "frequency", "measure_duty": "duty", "measure_vpp": "vpp",
394
+ "measure_rms": "rms", "measure_rise_time": "rise_time"}[m]
395
+ unit = {"frequency": "Hz", "duty": "%", "vpp": "V", "rms": "V", "rise_time": "s"}[key]
396
+ _measure(config, args, "scope", key, unit, channel_key="channel")
397
+
398
+ if args.command == "psu":
399
+ m = args.psu_cmd.replace("-", "_")
400
+ if m == "output":
401
+ do_psu_output(config, args, limits)
402
+ key = {"measure_voltage": "voltage", "measure_current": "current", "measure_power": "power"}[m]
403
+ unit = {"voltage": "V", "current": "A", "power": "W"}[key]
404
+ _measure(config, args, "psu", key, unit)
405
+
406
+ if args.command == "dmm":
407
+ m = args.dmm_cmd.replace("-", "_")
408
+ key = {"measure_voltage": "voltage", "measure_resistance": "resistance"}[m]
409
+ unit = {"voltage": "V", "resistance": "ohm"}[key]
410
+ _measure(config, args, "dmm", key, unit)
411
+
412
+ if args.command == "relay":
413
+ do_relay(config, args, limits)
414
+
415
+
416
+ if __name__ == "__main__":
417
+ main()
tools/lib/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ FirmwareLoop core library modules.
3
+ """
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tools/lib/instruments.py - thin instrument layer (GAP-006, v0.0.2).
4
+
5
+ Single shared module for instrument_cli.py AND pytest fixtures. Scope is
6
+ deliberately narrow: open / identify / query / normalize / timeout / close /
7
+ error mapping. NOT a framework - no device registry, no orchestration.
8
+
9
+ Backends:
10
+ visa - PyVISA + SCPI (real hardware only; never synthesized)
11
+ simulator - deterministic synthetic values for offline/CI (explicitly marked)
12
+
13
+ Rules (GAP-007): backend=visa must never fabricate data. A query the device
14
+ cannot answer raises InstrumentError('CAPABILITY_NOT_SUPPORTED', ...).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+
21
+ ERROR_CLASSES = (
22
+ "INSTRUMENT_NOT_FOUND",
23
+ "INSTRUMENT_TIMEOUT",
24
+ "CAPABILITY_NOT_SUPPORTED",
25
+ "HARDWARE_VALIDATION_FAILED",
26
+ "CONFIG_ERROR",
27
+ )
28
+
29
+
30
+ class InstrumentError(Exception):
31
+ """Structured instrument error carrying a FirmwareLoop error class."""
32
+
33
+ def __init__(self, error_class: str, message: str, detail=None):
34
+ if error_class not in ERROR_CLASSES:
35
+ raise ValueError(f"unknown instrument error class: {error_class}")
36
+ super().__init__(message)
37
+ self.error_class = error_class
38
+ self.message = message
39
+ self.detail = detail
40
+
41
+ def to_dict(self) -> dict:
42
+ body = {"ok": False, "error_class": self.error_class, "error": self.message}
43
+ if self.detail:
44
+ body["detail"] = self.detail
45
+ return body
46
+
47
+
48
+ # ---------------------------------------------------------------- clients
49
+ DEFAULT_SIM = {
50
+ "frequency": 20000.0, "duty": 50.0, "vpp": 3.3, "rms": 1.65,
51
+ "rise_time": 5.0e-9, "voltage": 3.3, "current": 0.035, "power": 0.1155,
52
+ "resistance": 1000.0,
53
+ }
54
+
55
+
56
+ class SimulatorClient:
57
+ """Synthetic backend (offline/CI). Measurements come from the lab config's
58
+ simulated_measurements table (falling back to deterministic defaults);
59
+ NEVER used for real evidence."""
60
+
61
+ def __init__(self, inst: dict, table: dict | None = None):
62
+ self.inst = inst
63
+ self.table = {**DEFAULT_SIM, **(table or {})}
64
+ self._closed = False
65
+
66
+ def identify(self) -> str:
67
+ return f"simulated {self.inst.get('name', 'instrument')} (backend=simulator)"
68
+
69
+ def query(self, scpi: str) -> str:
70
+ # map a measurement key from the SCPI string if possible
71
+ key = None
72
+ for token in ("FREQuency", "VPP", "PDUTy", "VRMS", "RTIMe", "VOLT", "CURR", "POW", "RES"):
73
+ if token.upper() in scpi.upper():
74
+ key = {"FREQUENCY": "frequency", "VPP": "vpp", "PDUTY": "duty",
75
+ "VRMS": "rms", "RTIME": "rise_time", "VOLT": "voltage",
76
+ "CURR": "current", "POW": "power", "RES": "resistance"}[token.upper()]
77
+ break
78
+ if key and key in self.table:
79
+ return str(self.table[key])
80
+ raise InstrumentError("CAPABILITY_NOT_SUPPORTED",
81
+ f"simulator has no synthetic value for SCPI '{scpi}'")
82
+
83
+ def write(self, scpi: str) -> None:
84
+ pass # simulator accepts writes (audited by the caller's safety policy)
85
+
86
+ def close(self) -> None:
87
+ self._closed = True
88
+
89
+
90
+ class VisaClient:
91
+ """PyVISA + SCPI. Real data only (GAP-007)."""
92
+
93
+ def __init__(self, inst: dict, timeout_ms: int = 5000):
94
+ try:
95
+ import pyvisa
96
+ except ImportError as exc: # pragma: no cover
97
+ raise InstrumentError("INSTRUMENT_NOT_FOUND",
98
+ "pyvisa is not installed; use backend=simulator for dry runs") from exc
99
+ self.rm = pyvisa.ResourceManager()
100
+ resource = inst.get("resource")
101
+ if not resource:
102
+ raise InstrumentError("CONFIG_ERROR",
103
+ "instrument has no 'resource' entry in lab config")
104
+ try:
105
+ self.inst = self.rm.open_resource(resource)
106
+ self.inst.timeout = timeout_ms
107
+ except Exception as exc: # noqa: BLE001
108
+ try:
109
+ self.rm.close()
110
+ except Exception: # noqa: BLE001
111
+ pass
112
+ raise InstrumentError("INSTRUMENT_TIMEOUT",
113
+ f"cannot open resource '{resource}': {exc}") from exc
114
+
115
+ def identify(self) -> str:
116
+ try:
117
+ return str(self.inst.query("*IDN?")).strip('"').strip()
118
+ except Exception as exc: # noqa: BLE001
119
+ raise InstrumentError("INSTRUMENT_TIMEOUT", f"*IDN? failed: {exc}") from exc
120
+
121
+ def query(self, scpi: str) -> str:
122
+ try:
123
+ return str(self.inst.query(scpi)).strip()
124
+ except Exception as exc: # noqa: BLE001
125
+ raise InstrumentError("INSTRUMENT_TIMEOUT", f"SCPI '{scpi}' failed: {exc}") from exc
126
+
127
+ def write(self, scpi: str) -> None:
128
+ try:
129
+ self.inst.write(scpi)
130
+ except Exception as exc: # noqa: BLE001
131
+ raise InstrumentError("INSTRUMENT_TIMEOUT", f"SCPI write '{scpi}' failed: {exc}") from exc
132
+
133
+ def close(self) -> None:
134
+ try:
135
+ self.inst.close()
136
+ self.rm.close()
137
+ except Exception: # noqa: BLE001
138
+ pass
139
+
140
+
141
+ # ---------------------------------------------------------------- factory + helpers
142
+ def open_instrument(inst: dict, table: dict | None = None, timeout_ms: int = 5000):
143
+ """Open a client for an instrument config entry."""
144
+ backend = (inst or {}).get("backend", "simulator")
145
+ if backend == "visa":
146
+ return VisaClient(inst, timeout_ms)
147
+ if backend == "simulator":
148
+ return SimulatorClient(inst, table)
149
+ raise InstrumentError("CONFIG_ERROR", f"unknown instrument backend: {backend}")
150
+
151
+
152
+ def query_measurement(client, scpi: str):
153
+ """Query + normalize a scalar measurement (float)."""
154
+ raw = client.query(scpi)
155
+ try:
156
+ return float(raw)
157
+ except (TypeError, ValueError) as exc:
158
+ raise InstrumentError("HARDWARE_VALIDATION_FAILED",
159
+ f"cannot normalize '{raw}' as a number", detail=scpi) from exc
160
+
161
+
162
+ def identify(client) -> str:
163
+ return client.identify()
164
+
165
+
166
+ def close(client) -> None:
167
+ try:
168
+ client.close()
169
+ except Exception: # noqa: BLE001
170
+ pass
171
+
172
+
173
+ def map_exception(exc: Exception) -> InstrumentError:
174
+ """Wrap a raw exception into a structured instrument error (error mapping)."""
175
+ if isinstance(exc, InstrumentError):
176
+ return exc
177
+ return InstrumentError("INSTRUMENT_TIMEOUT", f"instrument operation failed: {exc}")
178
+
179
+
180
+ def now() -> str:
181
+ return time.strftime("%Y-%m-%dT%H:%M:%S%z")