SCPI-Instrument-Control 2.0.0__py3-none-any.whl → 3.0.0__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.
Files changed (47) hide show
  1. scpi_control/__init__.py +1 -1
  2. scpi_control/channel.py +67 -18
  3. scpi_control/connection/mock/__init__.py +5 -0
  4. scpi_control/connection/{mock.py → mock/base.py} +46 -225
  5. scpi_control/connection/mock/helpers.py +25 -0
  6. scpi_control/connection/mock/lecroy.py +96 -0
  7. scpi_control/connection/mock/siglent.py +235 -0
  8. scpi_control/connection/mock/tektronix.py +226 -0
  9. scpi_control/exceptions.py +6 -0
  10. scpi_control/gui/widgets/daq_ai_panel.py +2 -8
  11. scpi_control/math_channel.py +5 -14
  12. scpi_control/measurement.py +66 -15
  13. scpi_control/measurement_badges.py +123 -0
  14. scpi_control/models.py +313 -21
  15. scpi_control/oscilloscope.py +39 -9
  16. scpi_control/report_generator/__init__.py +1 -1
  17. scpi_control/report_generator/analysis/__init__.py +1 -0
  18. scpi_control/report_generator/analysis/computed_analyzer.py +137 -0
  19. scpi_control/report_generator/app.py +74 -3
  20. scpi_control/report_generator/generators/markdown_generator.py +12 -8
  21. scpi_control/report_generator/generators/pdf_generator.py +147 -139
  22. scpi_control/report_generator/llm/analyzer.py +20 -8
  23. scpi_control/report_generator/llm/client.py +134 -91
  24. scpi_control/report_generator/llm/context_builder.py +13 -16
  25. scpi_control/report_generator/llm/daq_analyzer.py +21 -16
  26. scpi_control/report_generator/llm/prompts.py +17 -1
  27. scpi_control/report_generator/llm/tools.py +274 -0
  28. scpi_control/report_generator/main_window.py +37 -40
  29. scpi_control/report_generator/models/report_data.py +48 -21
  30. scpi_control/report_generator/models/template.py +26 -0
  31. scpi_control/report_generator/utils/waveform_analyzer.py +96 -25
  32. scpi_control/report_generator/utils/waveform_loader.py +271 -201
  33. scpi_control/report_generator/widgets/ai_analysis_panel.py +22 -3
  34. scpi_control/report_generator/widgets/metadata_panel.py +87 -1
  35. scpi_control/report_generator/widgets/pdf_preview_dialog.py +5 -2
  36. scpi_control/report_generator/widgets/template_manager_dialog.py +29 -0
  37. scpi_control/scpi_commands.py +632 -96
  38. scpi_control/trigger.py +75 -32
  39. scpi_control/waveform.py +61 -208
  40. scpi_control/waveform_schema.py +44 -0
  41. scpi_control/waveform_transfer.py +370 -0
  42. {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/METADATA +29 -4
  43. {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/RECORD +47 -36
  44. {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/WHEEL +0 -0
  45. {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/entry_points.txt +0 -0
  46. {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/licenses/LICENSE +0 -0
  47. {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/top_level.txt +0 -0
scpi_control/__init__.py CHANGED
@@ -25,7 +25,7 @@ For automated test report generation:
25
25
  from scpi_control.report_generator import ReportGenerator
26
26
  """
27
27
 
28
- __version__ = "2.0.0"
28
+ __version__ = "3.0.0"
29
29
 
30
30
  from scpi_control.exceptions import CommandError, SiglentConnectionError, SiglentError, SiglentTimeoutError
31
31
  from scpi_control.oscilloscope import Oscilloscope
scpi_control/channel.py CHANGED
@@ -4,7 +4,8 @@ import logging
4
4
  from typing import TYPE_CHECKING, Literal, Optional
5
5
 
6
6
  from scpi_control import exceptions
7
- from scpi_control.scpi_commands import coupling_from_wire, coupling_to_wire
7
+ from scpi_control.models import validate_channel
8
+ from scpi_control.scpi_commands import coupling_from_wire, coupling_to_wire, probe_from_wire, probe_to_wire
8
9
 
9
10
  if TYPE_CHECKING:
10
11
  from scpi_control.oscilloscope import Oscilloscope
@@ -31,10 +32,8 @@ class Channel:
31
32
  """
32
33
  self._scope = oscilloscope
33
34
  self._channel = channel_number
34
- self._prefix = f"C{channel_number}"
35
35
 
36
- if not 1 <= channel_number <= 4:
37
- raise exceptions.InvalidParameterError(f"Invalid channel number: {channel_number}. Must be 1-4.")
36
+ validate_channel(oscilloscope, channel_number)
38
37
 
39
38
  @property
40
39
  def _dialect(self) -> str:
@@ -51,9 +50,10 @@ class Channel:
51
50
  True if channel is displayed, False otherwise
52
51
  """
53
52
  response = self._scope.query(self._cmd("get_channel_display", ch=self._channel))
54
- # Response format: "C1:TRA ON" or "C1:TRA OFF"
55
- # Extract the last word
56
- return "ON" in response.upper()
53
+ # Response format: "C1:TRA ON"/"C1:TRA OFF" (legacy/modern) or a bare
54
+ # "1"/"0" numeric select (tektronix SELect:CH<x>? -- TBS p.144)
55
+ token = response.strip().split()[-1].upper() if response.strip() else ""
56
+ return token in ("ON", "1")
57
57
 
58
58
  @enabled.setter
59
59
  def enabled(self, value: bool) -> None:
@@ -171,13 +171,18 @@ class Channel:
171
171
  Returns:
172
172
  Probe ratio (e.g., 1.0 for 1X, 10.0 for 10X)
173
173
  """
174
+ # Probe commands are family-split on Tek (tek_tbs/tek_mso) and absent
175
+ # from the plain base table; gate before querying so a forced-dialect
176
+ # variant fallback raises cleanly instead of a raw KeyError.
177
+ if not self._scope._has_command("get_probe_ratio"):
178
+ raise exceptions.FeatureNotSupportedError(f"probe ratio is not supported on the {self._dialect} dialect")
174
179
  response = self._scope.query(self._cmd("get_probe_ratio", ch=self._channel))
175
180
  # Response may include echo like "C1:ATTN 10"
176
181
  if ":" in response:
177
182
  response = response.split(":", 1)[1]
178
183
  if " " in response:
179
184
  response = response.split(" ", 1)[1]
180
- return float(response.strip())
185
+ return probe_from_wire(self._dialect, response.strip())
181
186
 
182
187
  @probe_ratio.setter
183
188
  def probe_ratio(self, ratio: float) -> None:
@@ -190,7 +195,14 @@ class Channel:
190
195
  """
191
196
  if ratio <= 0:
192
197
  raise exceptions.InvalidParameterError(f"Probe ratio must be positive: {ratio}")
193
- self._scope.write(self._cmd("set_probe_ratio", ch=self._channel, ratio=ratio))
198
+ if not self._scope._has_command("set_probe_ratio"):
199
+ raise exceptions.FeatureNotSupportedError(f"probe ratio is not supported on the {self._dialect} dialect")
200
+ if self._dialect == "tektronix":
201
+ # Tek speaks probe attenuation as a gain factor (1/ratio); the
202
+ # tek_tbs/tek_mso family templates use the {gain} placeholder
203
+ self._scope.write(self._cmd("set_probe_ratio", ch=self._channel, gain=probe_to_wire(self._dialect, ratio)))
204
+ else:
205
+ self._scope.write(self._cmd("set_probe_ratio", ch=self._channel, ratio=probe_to_wire(self._dialect, ratio)))
194
206
  logger.info(f"Channel {self._channel} probe ratio set to {ratio}X")
195
207
 
196
208
  @property
@@ -200,9 +212,21 @@ class Channel:
200
212
  Returns:
201
213
  Bandwidth limit: 'ON', 'OFF', or frequency limit
202
214
  """
215
+ if self._dialect == "lecroy":
216
+ # BWL? is global: "C1,OFF,C2,20MHZ,..." pairs (MAUI p.7-18). The
217
+ # real LeCroy <mode> vocabulary is {OFF,20MHZ,200MHZ,...} -- there
218
+ # is no "ON" token, so any non-OFF wire token maps to the public
219
+ # "ON" (mirrors the modern/tektronix ON/OFF normalization below).
220
+ tokens = [t.strip().upper() for t in self._scope.query(self._cmd("get_bandwidth_limit")).split(",")]
221
+ try:
222
+ wire = tokens[tokens.index(f"C{self._channel}") + 1]
223
+ except (ValueError, IndexError):
224
+ return "OFF"
225
+ return "OFF" if wire == "OFF" else "ON"
203
226
  response = self._scope.query(self._cmd("get_bandwidth_limit", ch=self._channel)).strip().upper()
204
- if self._dialect == "modern":
205
- # Modern wire tokens are FULL/20M/200M; the API speaks ON/OFF
227
+ if self._dialect in ("modern", "tektronix"):
228
+ # Modern wire tokens are FULL/20M/200M; Tek's are FULl/TWENty (or
229
+ # a hertz value on MSO2) -- both speak ON/OFF at the public layer
206
230
  return "OFF" if response == "FULL" else "ON"
207
231
  return response
208
232
 
@@ -218,6 +242,21 @@ class Channel:
218
242
  raise exceptions.InvalidParameterError(f"Invalid bandwidth limit: {limit}. Must be ON, OFF, or FULL.")
219
243
  if self._dialect == "modern":
220
244
  wire = "FULL" if limit in ("OFF", "FULL") else "20M"
245
+ elif self._dialect == "tektronix":
246
+ if limit in ("OFF", "FULL"):
247
+ wire = "FULL"
248
+ elif getattr(getattr(self._scope, "model_capability", None), "scpi_variant", None) == "tek_mso":
249
+ # MSO 2-Series bandwidth vocabulary is {<NR3>|FULl} -- it has no
250
+ # TWENty keyword, so send 20 MHz as an explicit hertz value
251
+ # (2 Series MSO PM 077-1776-07 p.2-183).
252
+ wire = "20E6"
253
+ else:
254
+ # TBS1000C accepts the TWEnty keyword (TBS PM 077-1691-01 p.53).
255
+ wire = "TWENty"
256
+ elif self._dialect == "lecroy":
257
+ # LeCroy BWL <mode> vocabulary has no "ON" token -- {OFF,20MHZ,
258
+ # 200MHZ,...} (MAUI p.7-18). Map public ON to the 20MHz limit.
259
+ wire = "OFF" if limit in ("OFF", "FULL") else "20MHZ"
221
260
  else:
222
261
  wire = "OFF" if limit == "FULL" else limit
223
262
  self._scope.write(self._cmd("set_bandwidth_limit", ch=self._channel, limit=wire))
@@ -230,8 +269,9 @@ class Channel:
230
269
  Returns:
231
270
  Unit string (typically 'V' for volts)
232
271
  """
233
- # legacy-only command; not routed
234
- response = self._scope.query(f"{self._prefix}:UNIT?")
272
+ if not self._scope._has_command("get_channel_unit"):
273
+ raise exceptions.FeatureNotSupportedError(f"channel unit is not supported on the {self._dialect} dialect")
274
+ response = self._scope.query(self._cmd("get_channel_unit", ch=self._channel))
235
275
  return response.strip()
236
276
 
237
277
  @unit.setter
@@ -241,7 +281,9 @@ class Channel:
241
281
  Args:
242
282
  unit: Unit string ('V' for volts, 'A' for amps)
243
283
  """
244
- self._scope.write(f"{self._prefix}:UNIT {unit}")
284
+ if not self._scope._has_command("set_channel_unit"):
285
+ raise exceptions.FeatureNotSupportedError(f"channel unit is not supported on the {self._dialect} dialect")
286
+ self._scope.write(self._cmd("set_channel_unit", ch=self._channel, unit=unit))
245
287
  logger.info(f"Channel {self._channel} unit set to {unit}")
246
288
 
247
289
  def auto_scale(self) -> None:
@@ -261,16 +303,23 @@ class Channel:
261
303
  Returns:
262
304
  Dictionary with all channel settings
263
305
  """
264
- return {
306
+ config = {
265
307
  "channel": self._channel,
266
308
  "enabled": self.enabled,
267
309
  "coupling": self.coupling,
268
310
  "voltage_scale": self.voltage_scale,
269
311
  "voltage_offset": self.voltage_offset,
270
- "probe_ratio": self.probe_ratio,
271
- "bandwidth_limit": self.bandwidth_limit,
272
- "unit": self.unit,
273
312
  }
313
+ try:
314
+ config["probe_ratio"] = self.probe_ratio
315
+ except exceptions.FeatureNotSupportedError:
316
+ config["probe_ratio"] = None
317
+ config["bandwidth_limit"] = self.bandwidth_limit
318
+ try:
319
+ config["unit"] = self.unit
320
+ except exceptions.FeatureNotSupportedError:
321
+ config["unit"] = None
322
+ return config
274
323
 
275
324
  def __repr__(self) -> str:
276
325
  """String representation."""
@@ -0,0 +1,5 @@
1
+ """Mock connection package: shared shell in base, one personality module per vendor."""
2
+
3
+ from scpi_control.connection.mock.base import MOCK_SCREENSHOT_BMP, MockConnection
4
+
5
+ __all__ = ["MockConnection", "MOCK_SCREENSHOT_BMP"]
@@ -1,55 +1,21 @@
1
- """Mock connection implementation for deterministic offline SCPI testing."""
1
+ """Mock connection shell: constructor/state, connect/disconnect, PSU/AWG/DAQ handling,
2
+ and personality dispatch to the vendor-specific scope write/query/waveform modules."""
2
3
 
3
4
  from __future__ import annotations
4
5
 
5
6
  import re
6
- from typing import Dict, Iterable, List, Optional, Tuple, Union
7
+ from typing import Dict, Iterable, List, Optional, Union
7
8
 
8
9
  from scpi_control import exceptions
9
10
  from scpi_control.connection.base import BaseConnection
11
+ from scpi_control.connection.mock.helpers import MOCK_SCREENSHOT_BMP, _build_ieee_block
12
+ from scpi_control.connection.mock import lecroy, siglent, tektronix
10
13
  from scpi_control.models import detect_model_from_idn
11
14
 
12
-
13
- def _format_scientific(value: float, unit: str) -> str:
14
- """Format a numeric value with a unit using Siglent-style scientific notation."""
15
- return f"{value:.2E}{unit}"
16
-
17
-
18
- def _format_nr3(value: float) -> str:
19
- """Format a bare NR3 numeric value (no unit) as used by modern-dialect queries."""
20
- return f"{value:.2E}"
21
-
22
-
23
- def _build_ieee_block(payload: bytes) -> bytes:
24
- """Wrap payload in an IEEE-488.2 definite-length block: #<ndigits><length><payload>."""
25
- length_str = str(len(payload)).encode()
26
- return b"#" + str(len(length_str)).encode() + length_str + payload
27
-
28
-
29
- # Minimal valid 1x1 24bpp BMP, so a mock SCDP? screenshot decodes as a real image.
30
- MOCK_SCREENSHOT_BMP = bytes.fromhex("424d3a000000000000003600000028000000010000000100000001001800000000000400000000000000000000000000000000000000ffffff00")
31
-
32
-
33
- # Canonical PAVA? measurement values for the legacy dialect (mirrors real
34
- # hardware where PAVA? is legacy-only; the modern dialect has no equivalent).
35
- _MOCK_PAVA_VALUES: Dict[str, Tuple[str, str]] = {
36
- "PKPK": ("2.000E+00", "V"),
37
- "MAX": ("1.000E+00", "V"),
38
- "MIN": ("-1.000E+00", "V"),
39
- "AMPL": ("2.000E+00", "V"),
40
- "TOP": ("1.000E+00", "V"),
41
- "BASE": ("-1.000E+00", "V"),
42
- "CMEAN": ("0.000E+00", "V"),
43
- "MEAN": ("0.000E+00", "V"),
44
- "RMS": ("7.070E-01", "V"),
45
- "CRMS": ("7.070E-01", "V"),
46
- "FREQ": ("1.000E+03", "HZ"),
47
- "PER": ("1.000E-03", "S"),
48
- "RISE": ("3.500E-05", "S"),
49
- "FALL": ("3.500E-05", "S"),
50
- "WID": ("5.000E-04", "S"),
51
- "NWID": ("5.000E-04", "S"),
52
- "DUTY": ("5.000E+01", "%"),
15
+ _PERSONALITIES = {
16
+ "siglent": siglent,
17
+ "tektronix": tektronix,
18
+ "lecroy": lecroy,
53
19
  }
54
20
 
55
21
 
@@ -88,13 +54,16 @@ class MockConnection(BaseConnection):
88
54
  daq_mode: bool = False,
89
55
  daq_idn: str = "Keysight Technologies,34970A,MY12345678,A.01.02",
90
56
  daq_readings: str = "1.234,2.345,3.456",
57
+ tek_badges: Optional[Dict[int, Dict[str, str]]] = None,
91
58
  ):
92
59
  super().__init__(host, port, timeout)
93
60
  channels = channel_states.keys() if channel_states else range(1, 3)
94
61
 
95
62
  self.idn = idn
96
63
  # Personality: answer only the wire dialect this model actually speaks
97
- self.scope_dialect = detect_model_from_idn(idn).dialect
64
+ capability = detect_model_from_idn(idn)
65
+ self.scope_dialect = capability.dialect
66
+ self.scope_vendor = capability.vendor
98
67
  self._channel_enabled: Dict[int, bool] = {ch: channel_states.get(ch, True) if channel_states else True for ch in channels}
99
68
  self._voltage_scales: Dict[int, float] = {ch: voltage_scales.get(ch, 1.0) if voltage_scales else 1.0 for ch in channels}
100
69
  self._voltage_offsets: Dict[int, float] = {ch: voltage_offsets.get(ch, 0.0) if voltage_offsets else 0.0 for ch in channels}
@@ -116,6 +85,25 @@ class MockConnection(BaseConnection):
116
85
  self.trigger_level: Dict[int, float] = {ch: 0.0 for ch in channels}
117
86
  self.trigger_status: List[str] = trigger_status[:] if trigger_status else ["Stop"]
118
87
 
88
+ # Tektronix wire-vocabulary state (shared across tek_tbs/tek_mso variants)
89
+ self.tek_stop_after = "RUNSTOP"
90
+ self.data_source: int = 1
91
+ self.probe_gains: Dict[int, float] = {ch: 0.1 for ch in channels}
92
+ self.holdoff_time = 0.0
93
+ # Measurement badges: slot -> {"type": ..., "source": ...}. Seed via
94
+ # tek_badges to model badges a user created on the instrument.
95
+ self.badges: Dict[int, Dict[str, str]] = {n: dict(cfg) for n, cfg in (tek_badges or {}).items()}
96
+ if self.scope_vendor == "tektronix":
97
+ # Tek vocabulary differs from both Siglent dialects (guide TEKTRONIX_COMMANDS table)
98
+ self.trigger_mode = "AUTO"
99
+ self.trigger_slope = "RISE"
100
+ self.trigger_source = "CH1"
101
+ self._channel_coupling = {ch: "DC" for ch in channels}
102
+ if not trigger_status:
103
+ # "SAVE" is the Tek TRIGger:STATE? token for stopped; the shared
104
+ # default ["Stop"] is Siglent vocabulary and not a valid Tek state.
105
+ self.trigger_status = ["SAVE"]
106
+
119
107
  self.custom_responses = custom_responses or {}
120
108
  self.writes: List[str] = []
121
109
  self.queries: List[str] = []
@@ -360,102 +348,18 @@ class MockConnection(BaseConnection):
360
348
  self.awg_channels[ch]["enabled"] = enabled
361
349
  return
362
350
 
363
- # Oscilloscope commands
364
- if self.scope_dialect == "modern":
365
- if match := re.match(r":CHANnel(\d+):SWITch\s+(ON|OFF)", command, re.IGNORECASE):
366
- self._channel_enabled[int(match.group(1))] = match.group(2).upper() == "ON"
367
- return
368
- if match := re.match(r":CHANnel(\d+):SCALe\s+(.+)", command, re.IGNORECASE):
369
- ch = int(match.group(1))
370
- self._voltage_scales[ch] = float(match.group(2))
371
- self.scale_updates.setdefault(ch, []).append(float(match.group(2)))
372
- return
373
- if match := re.match(r":CHANnel(\d+):OFFSet\s+(.+)", command, re.IGNORECASE):
374
- self._voltage_offsets[int(match.group(1))] = float(match.group(2))
375
- return
376
- if match := re.match(r":CHANnel(\d+):COUPling\s+(\w+)", command, re.IGNORECASE):
377
- self._channel_coupling[int(match.group(1))] = match.group(2).upper()
378
- return
379
- if match := re.match(r":TIMebase:SCALe\s+(.+)", command, re.IGNORECASE):
380
- self.timebase = float(match.group(1))
381
- self.timebase_updates.append(self.timebase)
382
- return
383
- if match := re.match(r":TRIGger:MODE\s+(\w+)", command, re.IGNORECASE):
384
- self.trigger_mode = match.group(1) # stored as wire token, e.g. "NORMal" (guide p.482)
385
- if match.group(1).upper() == "SINGLE" and len(self.trigger_status) <= 1:
386
- # Status vocabulary matches real hardware: Ready while armed, Stop when done (same rule as the legacy ARM handler)
387
- self.trigger_status = ["Ready", "Stop"]
388
- return
389
- if re.match(r":TRIGger:RUN$", command, re.IGNORECASE):
390
- self.trigger_mode = "AUTO"
391
- return
392
- if re.match(r":TRIGger:STOP$", command, re.IGNORECASE):
393
- if len(self.trigger_status) <= 1:
394
- self.trigger_status = ["Stop"]
395
- return
396
- if match := re.match(r":TRIGger:TYPE\s+(\w+)", command, re.IGNORECASE):
397
- self.trigger_type = match.group(1).upper()
398
- return
399
- if match := re.match(r":TRIGger:EDGE:SOURce\s+(\w+)", command, re.IGNORECASE):
400
- self.trigger_source = match.group(1).upper()
401
- return
402
- if match := re.match(r":TRIGger:EDGE:LEVel\s+(.+)", command, re.IGNORECASE):
403
- self.trigger_level[1] = float(match.group(1))
404
- return
405
- if match := re.match(r":TRIGger:EDGE:SLOPe\s+(\w+)", command, re.IGNORECASE):
406
- self.trigger_slope = match.group(1) # wire token, e.g. "RISing" (guide p.494)
407
- return
408
- if match := re.match(r":TRIGger:EDGE:COUPling\s+(\w+)", command, re.IGNORECASE):
409
- self.trigger_coupling = match.group(1)
410
- return
411
- # Unknown modern writes fall through and are merely recorded,
412
- # mirroring real scopes which silently drop unknown commands
413
-
414
- if command.upper().startswith("TDIV "):
415
- value = command.split(" ", 1)[1]
416
- try:
417
- self.timebase = float(value)
418
- except ValueError:
419
- self.timebase = self.timebase
420
- self.timebase_updates.append(self.timebase)
421
- elif match := re.match(r"C(\d+):VDIV\s+(.+)", command, re.IGNORECASE):
422
- channel = int(match.group(1))
423
- value = float(match.group(2))
424
- self._voltage_scales[channel] = value
425
- self.scale_updates.setdefault(channel, []).append(value)
426
- elif match := re.match(r"C(\d+):OFST\s+(.+)", command, re.IGNORECASE):
427
- channel = int(match.group(1))
428
- value = float(match.group(2))
429
- self._voltage_offsets[channel] = value
430
- elif match := re.match(r"C(\d+):TRA\s+(ON|OFF)", command, re.IGNORECASE):
431
- channel = int(match.group(1))
432
- self._channel_enabled[channel] = match.group(2).upper() == "ON"
433
- elif match := re.match(r"C(\d+):CPL\s+(\w+)", command, re.IGNORECASE):
434
- self._channel_coupling[int(match.group(1))] = match.group(2).upper()
435
- elif command.upper().startswith("TRIG_MODE "):
436
- self.trigger_mode = command.split(" ", 1)[1].upper()
437
- elif command.upper().startswith("TRIG_SELECT "):
438
- _, params = command.split(" ", 1)
439
- trig_type, _, source = params.split(",")
440
- self.trigger_type = trig_type.strip().upper()
441
- self.trigger_source = source.strip().upper()
442
- elif match := re.match(r"C(\d+):TRSL\s+(\w+)", command, re.IGNORECASE):
443
- self.trigger_slope = match.group(2).upper()
444
- elif match := re.match(r"C(\d+):TRCP\s+(\w+)", command, re.IGNORECASE):
445
- self.trigger_coupling = match.group(2).upper()
446
- elif command.upper() == "ARM":
447
- # Simulate an acquisition that will eventually stop when no custom sequence is provided.
448
- # Status vocabulary matches real hardware: Ready while armed, Stop when done.
449
- if len(self.trigger_status) <= 1:
450
- self.trigger_status = ["Ready", "Stop"]
451
- elif match := re.match(r"C(\d+):TRLV\s+(.+)", command, re.IGNORECASE):
452
- channel = int(match.group(1))
453
- self.trigger_level[channel] = float(match.group(2))
454
- elif match := re.match(r"C(\d+):WF\?", command, re.IGNORECASE):
351
+ # Oscilloscope commands: C{n}:WF? channel recording is shared across every
352
+ # scope dialect/vendor (Tek's CURVe? uses data_source instead - Task 9), so
353
+ # it is recorded here before personality dispatch rather than inside a
354
+ # single vendor module's write handler.
355
+ if match := re.match(r"C(\d+):WF\?", command, re.IGNORECASE):
455
356
  channel = int(match.group(1))
456
357
  self._last_waveform_channel = channel
457
358
  self.waveform_requests.append(channel)
458
359
 
360
+ personality = _PERSONALITIES.get(self.scope_vendor, siglent)
361
+ personality.handle_write(self, command)
362
+
459
363
  def read(self) -> str:
460
364
  """Return an empty response for completeness."""
461
365
  if not self._connected:
@@ -693,88 +597,10 @@ class MockConnection(BaseConnection):
693
597
  return "CV"
694
598
  return "CV"
695
599
 
696
- if self.scope_dialect == "modern":
697
- if upper == ":TRIGGER:STATUS?": # enum Arm|Ready|Auto|Trig'd|Stop|Roll, p.483
698
- if len(self.trigger_status) > 1:
699
- return self.trigger_status.pop(0)
700
- return self.trigger_status[0]
701
- if upper == ":TRIGGER:MODE?": # mixed-case wire token, e.g. "NORMal", p.482
702
- return self.trigger_mode
703
- if upper == ":TRIGGER:TYPE?":
704
- return self.trigger_type
705
- if upper == ":TRIGGER:EDGE:SOURCE?": # bare token, p.495
706
- return self.trigger_source
707
- if upper == ":TRIGGER:EDGE:SLOPE?": # wire token e.g. "RISing", p.494
708
- return self.trigger_slope
709
- if upper == ":TRIGGER:EDGE:COUPLING?":
710
- return self.trigger_coupling
711
- if upper == ":TRIGGER:EDGE:LEVEL?": # bare NR3, p.492
712
- return _format_nr3(self.trigger_level.get(1, 0.0))
713
- if match := re.match(r":CHANNEL(\d+):SWITCH\?", upper):
714
- return "ON" if self._channel_enabled.get(int(match.group(1)), True) else "OFF"
715
- if match := re.match(r":CHANNEL(\d+):SCALE\?", upper): # bare NR3, p.58
716
- return _format_nr3(self._voltage_scales.get(int(match.group(1)), 1.0))
717
- if match := re.match(r":CHANNEL(\d+):OFFSET\?", upper): # bare NR3, p.56
718
- return _format_nr3(self._voltage_offsets.get(int(match.group(1)), 0.0))
719
- if match := re.match(r":CHANNEL(\d+):COUPLING\?", upper): # DC|AC|GND, p.51
720
- return self._channel_coupling.get(int(match.group(1)), "DC")
721
- if upper == ":TIMEBASE:SCALE?": # bare NR3, p.476
722
- return _format_nr3(self.timebase)
723
- if upper == ":ACQUIRE:SRATE?": # bare NR3, p.46
724
- return _format_nr3(self.sample_rate)
725
-
726
- if self.scope_dialect == "legacy":
727
- if match := re.match(r"C(\d+):VDIV\?", command, re.IGNORECASE):
728
- channel = int(match.group(1))
729
- value = self._voltage_scales.get(channel, 1.0)
730
- return _format_scientific(value, "V")
731
-
732
- if match := re.match(r"C(\d+):OFST\?", command, re.IGNORECASE):
733
- channel = int(match.group(1))
734
- value = self._voltage_offsets.get(channel, 0.0)
735
- return _format_scientific(value, "V")
736
-
737
- if match := re.match(r"C(\d+):TRA\?", command, re.IGNORECASE):
738
- channel = int(match.group(1))
739
- return "ON" if self._channel_enabled.get(channel, True) else "OFF"
740
-
741
- if match := re.match(r"C(\d+):CPL\?", command, re.IGNORECASE):
742
- return self._channel_coupling.get(int(match.group(1)), "D1M")
743
-
744
- if match := re.match(r"C(\d+):TRLV\?", command, re.IGNORECASE):
745
- channel = int(match.group(1))
746
- return _format_scientific(self.trigger_level.get(channel, 0.0), "V")
747
-
748
- if re.match(r"C(\d+):TRSL\?", command, re.IGNORECASE):
749
- return self.trigger_slope
750
-
751
- if re.match(r"C(\d+):TRCP\?", command, re.IGNORECASE):
752
- return self.trigger_coupling
753
-
754
- if upper == "TDIV?":
755
- return _format_scientific(self.timebase, "S")
756
-
757
- if upper == "SARA?":
758
- return _format_scientific(self.sample_rate, "Sa/s")
759
-
760
- if upper == "SAST?":
761
- if len(self.trigger_status) > 1:
762
- return self.trigger_status.pop(0)
763
- return self.trigger_status[0]
764
-
765
- if upper == "TRIG_MODE?":
766
- return self.trigger_mode
767
-
768
- if upper == "TRIG_SELECT?":
769
- return f"{self.trigger_type},SR,{self.trigger_source}"
770
-
771
- if match := re.match(r"PAVA\?\s*(\w+)\s*,\s*C?(\d+)", command, re.IGNORECASE):
772
- mtype = match.group(1).upper()
773
- channel = match.group(2)
774
- entry = _MOCK_PAVA_VALUES.get(mtype)
775
- if entry is not None:
776
- value, unit = entry
777
- return f"PAVA {mtype},C{channel},{value}{unit}"
600
+ personality = _PERSONALITIES.get(self.scope_vendor, siglent)
601
+ response = personality.handle_query(self, command)
602
+ if response is not None:
603
+ return response
778
604
 
779
605
  if self.psu_mode or self.awg_mode or self.daq_mode:
780
606
  return ""
@@ -787,10 +613,6 @@ class MockConnection(BaseConnection):
787
613
  """Convenience helper to query multiple commands sequentially."""
788
614
  return [self.query(cmd) for cmd in commands]
789
615
 
790
- def _build_waveform_block(self, payload: bytes) -> bytes:
791
- """Construct a minimal Siglent-style block response."""
792
- return b"DESC," + _build_ieee_block(payload)
793
-
794
616
  def read_raw(self, size: Optional[int] = None) -> bytes:
795
617
  """Return deterministic raw waveform data (or a mock screenshot BMP after SCDP?)."""
796
618
  if not self._connected:
@@ -801,6 +623,5 @@ class MockConnection(BaseConnection):
801
623
  # scope's SCDP? reply is parsed in screen_capture.py.
802
624
  return _build_ieee_block(MOCK_SCREENSHOT_BMP)
803
625
 
804
- channel = self._last_waveform_channel or next(iter(self._waveform_payloads.keys()))
805
- payload = self._waveform_payloads.get(channel, bytes())
806
- return self._build_waveform_block(payload)
626
+ personality = _PERSONALITIES.get(self.scope_vendor, siglent)
627
+ return personality.build_waveform_response(self)
@@ -0,0 +1,25 @@
1
+ """Vendor-neutral formatting helpers shared by the mock connection shell and every
2
+ personality module. Lives separately so personality modules never need to import
3
+ from `base` (which imports these helpers), avoiding an import cycle."""
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ def _format_scientific(value: float, unit: str) -> str:
9
+ """Format a numeric value with a unit using Siglent-style scientific notation."""
10
+ return f"{value:.2E}{unit}"
11
+
12
+
13
+ def _format_nr3(value: float) -> str:
14
+ """Format a bare NR3 numeric value (no unit) as used by modern-dialect queries."""
15
+ return f"{value:.2E}"
16
+
17
+
18
+ def _build_ieee_block(payload: bytes) -> bytes:
19
+ """Wrap payload in an IEEE-488.2 definite-length block: #<ndigits><length><payload>."""
20
+ length_str = str(len(payload)).encode()
21
+ return b"#" + str(len(length_str)).encode() + length_str + payload
22
+
23
+
24
+ # Minimal valid 1x1 24bpp BMP, so a mock SCDP? screenshot decodes as a real image.
25
+ MOCK_SCREENSHOT_BMP = bytes.fromhex("424d3a000000000000003600000028000000010000000100000001001800000000000400000000000000000000000000000000000000ffffff00")
@@ -0,0 +1,96 @@
1
+ """LeCroy MAUI mock personality.
2
+
3
+ Writes reuse the Siglent legacy handler (Siglent copied LeCroy's command set);
4
+ queries answer in CHDR OFF format: bare values, no unit suffixes.
5
+ """
6
+
7
+ import re
8
+ import struct
9
+ from typing import Optional
10
+
11
+ from scpi_control.connection.mock.helpers import _build_ieee_block, _format_nr3
12
+ from scpi_control.connection.mock.siglent import _MOCK_PAVA_VALUES, handle_write as _legacy_write
13
+
14
+ _INR_MAP = {"TRIG'D": "1"} # INR bit 0: new signal acquired
15
+
16
+
17
+ def handle_write(conn, command: str) -> bool:
18
+ upper = command.strip().upper()
19
+ if upper.startswith("CFMT ") or upper.startswith("CORD ") or upper == "CHDR OFF":
20
+ return True
21
+ if upper == "STOP":
22
+ conn.trigger_mode = "STOP" # TRMD? reports STOP after a STOP command
23
+ return True
24
+ if upper.startswith("BWL "):
25
+ # Legacy chain has no BWL handler; record as a no-op write explicitly
26
+ # so the fidelity contract (write returns True => consumed) holds.
27
+ return True
28
+ # The legacy chain understands TDIV/VDIV/TRA/CPL/TRIG_* — LeCroy originals.
29
+ # Its modern branch gates on scope_dialect == "modern", which is False here.
30
+ return _legacy_write(conn, command)
31
+
32
+
33
+ def handle_query(conn, command: str) -> Optional[str]:
34
+ upper = command.strip().upper()
35
+
36
+ if match := re.match(r"C(\d+):VDIV\?", upper):
37
+ return _format_nr3(conn._voltage_scales.get(int(match.group(1)), 1.0))
38
+ if match := re.match(r"C(\d+):OFST\?", upper):
39
+ return _format_nr3(conn._voltage_offsets.get(int(match.group(1)), 0.0))
40
+ if match := re.match(r"C(\d+):TRA\?", upper):
41
+ return "ON" if conn._channel_enabled.get(int(match.group(1)), True) else "OFF"
42
+ if match := re.match(r"C(\d+):CPL\?", upper):
43
+ return conn._channel_coupling.get(int(match.group(1)), "D1M")
44
+ if match := re.match(r"C(\d+):TRLV\?", upper):
45
+ return _format_nr3(conn.trigger_level.get(int(match.group(1)), 0.0))
46
+ if re.match(r"C(\d+):TRSL\?", upper):
47
+ return conn.trigger_slope
48
+ if re.match(r"C(\d+):TRCP\?", upper):
49
+ return conn.trigger_coupling
50
+ if match := re.match(r"C(\d+):ATTN\?", upper):
51
+ return "10"
52
+ if upper == "TDIV?":
53
+ return _format_nr3(conn.timebase)
54
+ if upper == "TRIG_MODE?" or upper == "TRMD?":
55
+ return conn.trigger_mode
56
+ if upper == "TRIG_SELECT?" or upper == "TRSE?":
57
+ return f"{conn.trigger_type},SR,{conn.trigger_source}"
58
+ if upper == "INR?":
59
+ if len(conn.trigger_status) > 1:
60
+ return _INR_MAP.get(conn.trigger_status.pop(0).upper(), "0")
61
+ return _INR_MAP.get(conn.trigger_status[0].upper(), "0")
62
+ if upper == "BWL?":
63
+ return ",".join(f"C{ch},OFF" for ch in sorted(conn._channel_enabled))
64
+ if upper.startswith("VBS? 'RETURN=APP.ACQUISITION.HORIZONTAL.SAMPLINGRATE'"):
65
+ return _format_nr3(conn.sample_rate)
66
+ if match := re.match(r"C(\d+):PAVA\?\s*(\w+)", upper):
67
+ mtype = match.group(2)
68
+ entry = _MOCK_PAVA_VALUES.get(mtype)
69
+ if entry is not None:
70
+ value, _unit = entry
71
+ # CHDR OFF suppresses units on LeCroy; native shape is <param>,<value>,<state>
72
+ return f"{mtype},{value},OK"
73
+ return None
74
+
75
+
76
+ def build_waveform_response(conn) -> bytes:
77
+ """Construct a LeCroy WAVEDESC + sample-array block (WF? ALL, CORD LO)."""
78
+ channel = conn._last_waveform_channel or next(iter(conn._waveform_payloads))
79
+ codes = conn._waveform_payloads.get(channel, bytes())
80
+ gain = conn._voltage_scales.get(channel, 1.0) / 25.0 # mirror Siglent scaling for comparable volts
81
+ desc = bytearray(346)
82
+ desc[0:8] = b"WAVEDESC"
83
+ struct.pack_into("<h", desc, 32, 0)
84
+ struct.pack_into("<i", desc, 36, 346)
85
+ struct.pack_into("<i", desc, 40, 0)
86
+ # TRIGTIME_ARRAY (offset 48) and RIS_TIME_ARRAY (offset 52) lengths: 0 for
87
+ # this single-shot mock (the bytearray is already zero-filled; packed
88
+ # explicitly to document the layout parse_wavedesc now skips).
89
+ struct.pack_into("<i", desc, 48, 0)
90
+ struct.pack_into("<i", desc, 52, 0)
91
+ struct.pack_into("<i", desc, 116, len(codes))
92
+ struct.pack_into("<f", desc, 156, gain)
93
+ struct.pack_into("<f", desc, 160, conn._voltage_offsets.get(channel, 0.0))
94
+ struct.pack_into("<f", desc, 176, 1.0 / conn.sample_rate)
95
+ struct.pack_into("<d", desc, 180, 0.0)
96
+ return _build_ieee_block(bytes(desc) + codes)