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.
- firmwareloop-0.0.8.dist-info/METADATA +268 -0
- firmwareloop-0.0.8.dist-info/RECORD +26 -0
- firmwareloop-0.0.8.dist-info/WHEEL +5 -0
- firmwareloop-0.0.8.dist-info/entry_points.txt +5 -0
- firmwareloop-0.0.8.dist-info/licenses/LICENSE +21 -0
- firmwareloop-0.0.8.dist-info/top_level.txt +1 -0
- tools/__init__.py +5 -0
- tools/acceptance-scenario.ps1 +455 -0
- tools/build.ps1 +208 -0
- tools/can.ps1 +130 -0
- tools/check-qoder-mcp.ps1 +111 -0
- tools/common/build-backends.psm1 +294 -0
- tools/common/fw.psm1 +311 -0
- tools/common/uart_probe.py +100 -0
- tools/doctor.ps1 +148 -0
- tools/flash.ps1 +181 -0
- tools/fw_mcp_server.py +1272 -0
- tools/instrument_cli.py +417 -0
- tools/lib/__init__.py +3 -0
- tools/lib/instruments.py +181 -0
- tools/logic_capture.ps1 +261 -0
- tools/logic_decode.ps1 +307 -0
- tools/reset.ps1 +141 -0
- tools/setup-agent-mcp.ps1 +147 -0
- tools/test-backends.ps1 +100 -0
- tools/test.ps1 +194 -0
tools/instrument_cli.py
ADDED
|
@@ -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
tools/lib/instruments.py
ADDED
|
@@ -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")
|