SCPI-Instrument-Control 1.1.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 (83) hide show
  1. scpi_control/__init__.py +1 -1
  2. scpi_control/automation.py +28 -12
  3. scpi_control/channel.py +92 -31
  4. scpi_control/connection/base.py +4 -0
  5. scpi_control/connection/mock/__init__.py +5 -0
  6. scpi_control/connection/{mock.py → mock/base.py} +68 -86
  7. scpi_control/connection/mock/helpers.py +25 -0
  8. scpi_control/connection/mock/lecroy.py +96 -0
  9. scpi_control/connection/mock/siglent.py +235 -0
  10. scpi_control/connection/mock/tektronix.py +226 -0
  11. scpi_control/connection/socket.py +172 -81
  12. scpi_control/connection/visa_connection.py +7 -5
  13. scpi_control/exceptions.py +6 -0
  14. scpi_control/function_generator.py +4 -4
  15. scpi_control/gui/app.py +4 -4
  16. scpi_control/gui/connection_manager.py +7 -7
  17. scpi_control/gui/main_window.py +15 -19
  18. scpi_control/gui/widgets/daq_ai_panel.py +4 -12
  19. scpi_control/gui/widgets/daq_data_view.py +2 -4
  20. scpi_control/gui/widgets/daq_scan_config.py +4 -8
  21. scpi_control/gui/widgets/math_panel.py +2 -4
  22. scpi_control/gui/widgets/psu_control.py +2 -4
  23. scpi_control/gui/widgets/terminal_widget.py +10 -20
  24. scpi_control/gui/widgets/timebase_control.py +4 -8
  25. scpi_control/gui/widgets/vector_graphics_panel.py +2 -2
  26. scpi_control/math_channel.py +5 -14
  27. scpi_control/measurement.py +66 -15
  28. scpi_control/measurement_badges.py +123 -0
  29. scpi_control/models.py +333 -20
  30. scpi_control/oscilloscope.py +82 -22
  31. scpi_control/power_supply.py +5 -5
  32. scpi_control/report_generator/__init__.py +1 -1
  33. scpi_control/report_generator/analysis/__init__.py +1 -0
  34. scpi_control/report_generator/analysis/computed_analyzer.py +137 -0
  35. scpi_control/report_generator/app.py +74 -3
  36. scpi_control/report_generator/generators/markdown_generator.py +12 -8
  37. scpi_control/report_generator/generators/pdf_generator.py +147 -139
  38. scpi_control/report_generator/llm/analyzer.py +20 -8
  39. scpi_control/report_generator/llm/client.py +134 -91
  40. scpi_control/report_generator/llm/context_builder.py +13 -16
  41. scpi_control/report_generator/llm/daq_analyzer.py +21 -16
  42. scpi_control/report_generator/llm/prompts.py +17 -1
  43. scpi_control/report_generator/llm/tools.py +274 -0
  44. scpi_control/report_generator/main_window.py +37 -40
  45. scpi_control/report_generator/models/report_data.py +48 -21
  46. scpi_control/report_generator/models/template.py +26 -0
  47. scpi_control/report_generator/utils/waveform_analyzer.py +96 -25
  48. scpi_control/report_generator/utils/waveform_loader.py +271 -201
  49. scpi_control/report_generator/widgets/ai_analysis_panel.py +22 -3
  50. scpi_control/report_generator/widgets/metadata_panel.py +87 -1
  51. scpi_control/report_generator/widgets/pdf_preview_dialog.py +5 -2
  52. scpi_control/report_generator/widgets/template_manager_dialog.py +29 -0
  53. scpi_control/scpi_commands.py +744 -75
  54. scpi_control/server/__init__.py +0 -0
  55. scpi_control/server/__main__.py +19 -0
  56. scpi_control/server/api/__init__.py +0 -0
  57. scpi_control/server/api/discovery.py +35 -0
  58. scpi_control/server/api/scope.py +467 -0
  59. scpi_control/server/api/sessions.py +50 -0
  60. scpi_control/server/api/stream.py +103 -0
  61. scpi_control/server/app.py +91 -0
  62. scpi_control/server/compute.py +109 -0
  63. scpi_control/server/discovery.py +126 -0
  64. scpi_control/server/recorder.py +66 -0
  65. scpi_control/server/schemas.py +113 -0
  66. scpi_control/server/sessions.py +392 -0
  67. scpi_control/server/static/assets/index-0Vpguwg6.js +9 -0
  68. scpi_control/server/static/assets/index-CyUPLIHk.css +1 -0
  69. scpi_control/server/static/index.html +13 -0
  70. scpi_control/trigger.py +138 -51
  71. scpi_control/vector_graphics.py +3 -2
  72. scpi_control/waveform.py +77 -206
  73. scpi_control/waveform_schema.py +44 -0
  74. scpi_control/waveform_transfer.py +370 -0
  75. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/METADATA +87 -21
  76. scpi_instrument_control-3.0.0.dist-info/RECORD +144 -0
  77. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/WHEEL +1 -1
  78. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/entry_points.txt +1 -0
  79. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/top_level.txt +0 -1
  80. scpi_instrument_control-1.1.0.dist-info/RECORD +0 -119
  81. siglent/__init__.py +0 -35
  82. siglent/exceptions.py +0 -40
  83. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/licenses/LICENSE +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__ = "1.1.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
@@ -63,19 +63,21 @@ class DataCollector:
63
63
  def __init__(
64
64
  self,
65
65
  host: str,
66
- port: int = 5024,
66
+ port: int = 5025,
67
67
  timeout: float = 5.0,
68
68
  connection: Optional[BaseConnection] = None,
69
+ dialect: Optional[str] = None,
69
70
  ):
70
71
  """Initialize data collector.
71
72
 
72
73
  Args:
73
74
  host: IP address or hostname of the oscilloscope
74
- port: TCP port for SCPI communication (default: 5024)
75
+ port: TCP port for SCPI communication (default: 5025, the Siglent raw SCPI socket; 5024 is the telnet-style port with prompts and is not recommended)
75
76
  timeout: Command timeout in seconds (default: 5.0)
76
77
  connection: Optional connection implementation (e.g., MockConnection for offline tests)
78
+ dialect: Optional SCPI dialect override passed to Oscilloscope ("legacy" or "modern"; None auto-detects)
77
79
  """
78
- self.scope = Oscilloscope(host, port, timeout, connection=connection)
80
+ self.scope = Oscilloscope(host, port, timeout, connection=connection, dialect=dialect)
79
81
  self._connected = False
80
82
 
81
83
  def connect(self) -> None:
@@ -472,19 +474,21 @@ class TriggerWaitCollector:
472
474
  def __init__(
473
475
  self,
474
476
  host: str,
475
- port: int = 5024,
477
+ port: int = 5025,
476
478
  timeout: float = 5.0,
477
479
  connection: Optional[BaseConnection] = None,
480
+ dialect: Optional[str] = None,
478
481
  ):
479
482
  """Initialize trigger wait collector.
480
483
 
481
484
  Args:
482
485
  host: IP address or hostname of the oscilloscope
483
- port: TCP port for SCPI communication
486
+ port: TCP port for SCPI communication (default: 5025, the Siglent raw SCPI socket; 5024 is the telnet-style port with prompts and is not recommended)
484
487
  timeout: Command timeout in seconds
485
488
  connection: Optional connection implementation (e.g., MockConnection for offline tests)
489
+ dialect: Optional SCPI dialect override passed to Oscilloscope ("legacy" or "modern"; None auto-detects)
486
490
  """
487
- self.collector = DataCollector(host, port, timeout, connection=connection)
491
+ self.collector = DataCollector(host, port, timeout, connection=connection, dialect=dialect)
488
492
 
489
493
  def __enter__(self):
490
494
  """Context manager entry."""
@@ -505,6 +509,11 @@ class TriggerWaitCollector:
505
509
  ) -> Optional[Dict[int, WaveformData]]:
506
510
  """Wait for a trigger event and capture waveform.
507
511
 
512
+ If the trigger mode has been set to NORMAL beforehand (e.g. via
513
+ ``trigger.set_mode('NORMAL')``), that mode is preserved and the wait
514
+ completes on the first trigger event. In any other mode, the scope is
515
+ switched to SINGLE and armed for a one-shot acquisition.
516
+
508
517
  Args:
509
518
  channels: List of channel numbers to capture
510
519
  max_wait: Maximum time to wait for trigger in seconds
@@ -526,16 +535,23 @@ class TriggerWaitCollector:
526
535
  ... if data:
527
536
  ... print("Trigger captured!")
528
537
  """
529
- # Set to NORMAL trigger mode
530
- self.collector.scope.trigger.mode = "NORM"
531
- self.collector.scope.trigger_single()
538
+ # Honor a user-configured NORMAL trigger mode; otherwise arm a
539
+ # one-shot SINGLE acquisition. trigger.mode is dialect-normalized.
540
+ current_mode = self.collector.scope.trigger.mode
541
+
542
+ if current_mode == "NORM":
543
+ # NORMAL mode re-arms after every trigger and never reports
544
+ # STOP, so watch for the trigger event itself.
545
+ done_states = {"TRIGD", "STOP"}
546
+ else:
547
+ self.collector.scope.trigger_single()
548
+ done_states = {"STOP"}
532
549
 
533
550
  start_time = time.time()
534
551
  while (time.time() - start_time) < max_wait:
535
- # Check trigger status
536
- status = self.collector.scope.query(":TRIG:STAT?").strip()
552
+ status = self.collector.scope.acquisition_status()
537
553
 
538
- if status == "Stop":
554
+ if status in done_states:
539
555
  # Trigger occurred, capture waveform
540
556
  logger.info("Trigger detected!")
541
557
  waveforms = {}
scpi_control/channel.py CHANGED
@@ -4,6 +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.models import validate_channel
8
+ from scpi_control.scpi_commands import coupling_from_wire, coupling_to_wire, probe_from_wire, probe_to_wire
7
9
 
8
10
  if TYPE_CHECKING:
9
11
  from scpi_control.oscilloscope import Oscilloscope
@@ -30,10 +32,15 @@ class Channel:
30
32
  """
31
33
  self._scope = oscilloscope
32
34
  self._channel = channel_number
33
- self._prefix = f"C{channel_number}"
34
35
 
35
- if not 1 <= channel_number <= 4:
36
- raise exceptions.InvalidParameterError(f"Invalid channel number: {channel_number}. Must be 1-4.")
36
+ validate_channel(oscilloscope, channel_number)
37
+
38
+ @property
39
+ def _dialect(self) -> str:
40
+ return getattr(self._scope, "dialect", None) or "legacy"
41
+
42
+ def _cmd(self, name: str, **kwargs) -> str:
43
+ return self._scope._get_command(name, **kwargs)
37
44
 
38
45
  @property
39
46
  def enabled(self) -> bool:
@@ -42,10 +49,11 @@ class Channel:
42
49
  Returns:
43
50
  True if channel is displayed, False otherwise
44
51
  """
45
- response = self._scope.query(f"{self._prefix}:TRA?")
46
- # Response format: "C1:TRA ON" or "C1:TRA OFF"
47
- # Extract the last word
48
- return "ON" in response.upper()
52
+ response = self._scope.query(self._cmd("get_channel_display", ch=self._channel))
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")
49
57
 
50
58
  @enabled.setter
51
59
  def enabled(self, value: bool) -> None:
@@ -55,7 +63,7 @@ class Channel:
55
63
  value: True to display channel, False to hide
56
64
  """
57
65
  state = "ON" if value else "OFF"
58
- self._scope.write(f"{self._prefix}:TRA {state}")
66
+ self._scope.write(self._cmd("set_channel_display", ch=self._channel, state=state))
59
67
  logger.info(f"Channel {self._channel} {'enabled' if value else 'disabled'}")
60
68
 
61
69
  def enable(self) -> None:
@@ -73,8 +81,7 @@ class Channel:
73
81
  Returns:
74
82
  Coupling mode: 'DC', 'AC', or 'GND'
75
83
  """
76
- response = self._scope.query(f"{self._prefix}:CPL?")
77
- return response.upper().strip()
84
+ return coupling_from_wire(self._dialect, self._scope.query(self._cmd("get_coupling", ch=self._channel)))
78
85
 
79
86
  @coupling.setter
80
87
  def coupling(self, mode: CouplingType) -> None:
@@ -86,7 +93,7 @@ class Channel:
86
93
  mode = mode.upper()
87
94
  if mode not in ["DC", "AC", "GND"]:
88
95
  raise exceptions.InvalidParameterError(f"Invalid coupling mode: {mode}. Must be DC, AC, or GND.")
89
- self._scope.write(f"{self._prefix}:CPL {mode}")
96
+ self._scope.write(self._cmd("set_coupling", ch=self._channel, coupling=coupling_to_wire(self._dialect, mode)))
90
97
  logger.info(f"Channel {self._channel} coupling set to {mode}")
91
98
 
92
99
  @property
@@ -96,7 +103,7 @@ class Channel:
96
103
  Returns:
97
104
  Voltage scale in volts/division
98
105
  """
99
- response = self._scope.query(f"{self._prefix}:VDIV?")
106
+ response = self._scope.query(self._cmd("get_voltage_div", ch=self._channel))
100
107
  # Response may include echo like "C1:VDIV 1.0E+00V" or just "1.0E+00V"
101
108
  # Remove the echo prefix if present
102
109
  if ":" in response:
@@ -120,7 +127,7 @@ class Channel:
120
127
  """
121
128
  if volts_per_div <= 0:
122
129
  raise exceptions.InvalidParameterError(f"Voltage scale must be positive: {volts_per_div}")
123
- self._scope.write(f"{self._prefix}:VDIV {volts_per_div}")
130
+ self._scope.write(self._cmd("set_voltage_div", ch=self._channel, vdiv=volts_per_div))
124
131
  logger.info(f"Channel {self._channel} scale set to {volts_per_div} V/div")
125
132
 
126
133
  def set_scale(self, volts_per_div: float) -> None:
@@ -138,7 +145,7 @@ class Channel:
138
145
  Returns:
139
146
  Offset voltage in volts
140
147
  """
141
- response = self._scope.query(f"{self._prefix}:OFST?")
148
+ response = self._scope.query(self._cmd("get_voltage_offset", ch=self._channel))
142
149
  # Response may include echo like "C1:OFST 1.0E+00V"
143
150
  if ":" in response:
144
151
  response = response.split(":", 1)[1]
@@ -154,7 +161,7 @@ class Channel:
154
161
  Args:
155
162
  offset: Offset voltage in volts
156
163
  """
157
- self._scope.write(f"{self._prefix}:OFST {offset}")
164
+ self._scope.write(self._cmd("set_voltage_offset", ch=self._channel, offset=offset))
158
165
  logger.info(f"Channel {self._channel} offset set to {offset} V")
159
166
 
160
167
  @property
@@ -164,13 +171,18 @@ class Channel:
164
171
  Returns:
165
172
  Probe ratio (e.g., 1.0 for 1X, 10.0 for 10X)
166
173
  """
167
- response = self._scope.query(f"{self._prefix}:ATTN?")
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")
179
+ response = self._scope.query(self._cmd("get_probe_ratio", ch=self._channel))
168
180
  # Response may include echo like "C1:ATTN 10"
169
181
  if ":" in response:
170
182
  response = response.split(":", 1)[1]
171
183
  if " " in response:
172
184
  response = response.split(" ", 1)[1]
173
- return float(response.strip())
185
+ return probe_from_wire(self._dialect, response.strip())
174
186
 
175
187
  @probe_ratio.setter
176
188
  def probe_ratio(self, ratio: float) -> None:
@@ -183,7 +195,14 @@ class Channel:
183
195
  """
184
196
  if ratio <= 0:
185
197
  raise exceptions.InvalidParameterError(f"Probe ratio must be positive: {ratio}")
186
- self._scope.write(f"{self._prefix}:ATTN {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)))
187
206
  logger.info(f"Channel {self._channel} probe ratio set to {ratio}X")
188
207
 
189
208
  @property
@@ -193,8 +212,23 @@ class Channel:
193
212
  Returns:
194
213
  Bandwidth limit: 'ON', 'OFF', or frequency limit
195
214
  """
196
- response = self._scope.query(f"{self._prefix}:BWL?")
197
- return response.upper().strip()
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"
226
+ response = self._scope.query(self._cmd("get_bandwidth_limit", ch=self._channel)).strip().upper()
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
230
+ return "OFF" if response == "FULL" else "ON"
231
+ return response
198
232
 
199
233
  @bandwidth_limit.setter
200
234
  def bandwidth_limit(self, limit: BandwidthLimitType) -> None:
@@ -206,10 +240,26 @@ class Channel:
206
240
  limit = limit.upper()
207
241
  if limit not in ["ON", "OFF", "FULL"]:
208
242
  raise exceptions.InvalidParameterError(f"Invalid bandwidth limit: {limit}. Must be ON, OFF, or FULL.")
209
- # Convert FULL to OFF for compatibility
210
- if limit == "FULL":
211
- limit = "OFF"
212
- self._scope.write(f"{self._prefix}:BWL {limit}")
243
+ if self._dialect == "modern":
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"
260
+ else:
261
+ wire = "OFF" if limit == "FULL" else limit
262
+ self._scope.write(self._cmd("set_bandwidth_limit", ch=self._channel, limit=wire))
213
263
  logger.info(f"Channel {self._channel} bandwidth limit set to {limit}")
214
264
 
215
265
  @property
@@ -219,7 +269,9 @@ class Channel:
219
269
  Returns:
220
270
  Unit string (typically 'V' for volts)
221
271
  """
222
- 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))
223
275
  return response.strip()
224
276
 
225
277
  @unit.setter
@@ -229,7 +281,9 @@ class Channel:
229
281
  Args:
230
282
  unit: Unit string ('V' for volts, 'A' for amps)
231
283
  """
232
- 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))
233
287
  logger.info(f"Channel {self._channel} unit set to {unit}")
234
288
 
235
289
  def auto_scale(self) -> None:
@@ -241,7 +295,7 @@ class Channel:
241
295
  # For per-channel auto-scale, we might need to use different commands
242
296
  # This is a basic implementation that may need adjustment for SD824x
243
297
  logger.info(f"Auto-scaling channel {self._channel}")
244
- self._scope.write("ASET")
298
+ self._scope.write(self._cmd("auto_setup"))
245
299
 
246
300
  def get_configuration(self) -> dict:
247
301
  """Get all channel configuration parameters.
@@ -249,16 +303,23 @@ class Channel:
249
303
  Returns:
250
304
  Dictionary with all channel settings
251
305
  """
252
- return {
306
+ config = {
253
307
  "channel": self._channel,
254
308
  "enabled": self.enabled,
255
309
  "coupling": self.coupling,
256
310
  "voltage_scale": self.voltage_scale,
257
311
  "voltage_offset": self.voltage_offset,
258
- "probe_ratio": self.probe_ratio,
259
- "bandwidth_limit": self.bandwidth_limit,
260
- "unit": self.unit,
261
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
262
323
 
263
324
  def __repr__(self) -> str:
264
325
  """String representation."""
@@ -1,5 +1,6 @@
1
1
  """Abstract base class for oscilloscope connections."""
2
2
 
3
+ import threading
3
4
  from abc import ABC, abstractmethod
4
5
  from typing import Optional, Union
5
6
 
@@ -19,6 +20,9 @@ class BaseConnection(ABC):
19
20
  self.port = port
20
21
  self.timeout = timeout
21
22
  self._connected = False
23
+ # Reentrant so query() can hold it across its own write()/read() calls.
24
+ # Callers doing compound exchanges (write + read_raw) must hold it too.
25
+ self.lock = threading.RLock()
22
26
 
23
27
  @abstractmethod
24
28
  def connect(self) -> None:
@@ -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,4 +1,5 @@
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
 
@@ -7,11 +8,15 @@ 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
13
+ from scpi_control.models import detect_model_from_idn
10
14
 
11
-
12
- def _format_scientific(value: float, unit: str) -> str:
13
- """Format a numeric value with a unit using Siglent-style scientific notation."""
14
- return f"{value:.2E}{unit}"
15
+ _PERSONALITIES = {
16
+ "siglent": siglent,
17
+ "tektronix": tektronix,
18
+ "lecroy": lecroy,
19
+ }
15
20
 
16
21
 
17
22
  class MockConnection(BaseConnection):
@@ -49,24 +54,56 @@ class MockConnection(BaseConnection):
49
54
  daq_mode: bool = False,
50
55
  daq_idn: str = "Keysight Technologies,34970A,MY12345678,A.01.02",
51
56
  daq_readings: str = "1.234,2.345,3.456",
57
+ tek_badges: Optional[Dict[int, Dict[str, str]]] = None,
52
58
  ):
53
59
  super().__init__(host, port, timeout)
54
60
  channels = channel_states.keys() if channel_states else range(1, 3)
55
61
 
56
62
  self.idn = idn
63
+ # Personality: answer only the wire dialect this model actually speaks
64
+ capability = detect_model_from_idn(idn)
65
+ self.scope_dialect = capability.dialect
66
+ self.scope_vendor = capability.vendor
57
67
  self._channel_enabled: Dict[int, bool] = {ch: channel_states.get(ch, True) if channel_states else True for ch in channels}
58
68
  self._voltage_scales: Dict[int, float] = {ch: voltage_scales.get(ch, 1.0) if voltage_scales else 1.0 for ch in channels}
59
69
  self._voltage_offsets: Dict[int, float] = {ch: voltage_offsets.get(ch, 0.0) if voltage_offsets else 0.0 for ch in channels}
60
70
  self._waveform_payloads: Dict[int, bytes] = {ch: (waveform_payloads.get(ch, bytes([0, 25, 50, 75])) if waveform_payloads else bytes([0, 25, 50, 75])) for ch in channels}
71
+ self._channel_coupling: Dict[int, str] = {ch: "D1M" for ch in channels}
61
72
 
62
73
  self.sample_rate = sample_rate
63
74
  self.timebase = timebase
64
- self.trigger_mode = "STOP"
65
75
  self.trigger_type = "EDGE"
66
76
  self.trigger_source = "C1"
77
+ if self.scope_dialect == "modern":
78
+ # Initial wire tokens must be modern vocabulary (guide p.482, p.494)
79
+ self.trigger_mode = "AUTO"
80
+ self.trigger_slope = "RISing"
81
+ else:
82
+ self.trigger_mode = "STOP"
83
+ self.trigger_slope = "POS"
84
+ self.trigger_coupling = "DC"
67
85
  self.trigger_level: Dict[int, float] = {ch: 0.0 for ch in channels}
68
86
  self.trigger_status: List[str] = trigger_status[:] if trigger_status else ["Stop"]
69
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
+
70
107
  self.custom_responses = custom_responses or {}
71
108
  self.writes: List[str] = []
72
109
  self.queries: List[str] = []
@@ -311,45 +348,18 @@ class MockConnection(BaseConnection):
311
348
  self.awg_channels[ch]["enabled"] = enabled
312
349
  return
313
350
 
314
- # Oscilloscope commands
315
- if command.upper().startswith("TDIV "):
316
- value = command.split(" ", 1)[1]
317
- try:
318
- self.timebase = float(value)
319
- except ValueError:
320
- self.timebase = self.timebase
321
- self.timebase_updates.append(self.timebase)
322
- elif match := re.match(r"C(\d+):VDIV\s+(.+)", command, re.IGNORECASE):
323
- channel = int(match.group(1))
324
- value = float(match.group(2))
325
- self._voltage_scales[channel] = value
326
- self.scale_updates.setdefault(channel, []).append(value)
327
- elif match := re.match(r"C(\d+):OFST\s+(.+)", command, re.IGNORECASE):
328
- channel = int(match.group(1))
329
- value = float(match.group(2))
330
- self._voltage_offsets[channel] = value
331
- elif match := re.match(r"C(\d+):TRA\s+(ON|OFF)", command, re.IGNORECASE):
332
- channel = int(match.group(1))
333
- self._channel_enabled[channel] = match.group(2).upper() == "ON"
334
- elif command.upper().startswith("TRIG_MODE "):
335
- self.trigger_mode = command.split(" ", 1)[1].upper()
336
- elif command.upper().startswith("TRIG_SELECT "):
337
- _, params = command.split(" ", 1)
338
- trig_type, _, source = params.split(",")
339
- self.trigger_type = trig_type.strip().upper()
340
- self.trigger_source = source.strip().upper()
341
- elif command.upper() == "ARM":
342
- # Simulate an acquisition that will eventually stop when no custom sequence is provided
343
- if len(self.trigger_status) <= 1:
344
- self.trigger_status = ["Run", "Stop"]
345
- elif match := re.match(r"C(\d+):TRLV\s+(.+)", command, re.IGNORECASE):
346
- channel = int(match.group(1))
347
- self.trigger_level[channel] = float(match.group(2))
348
- 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):
349
356
  channel = int(match.group(1))
350
357
  self._last_waveform_channel = channel
351
358
  self.waveform_requests.append(channel)
352
359
 
360
+ personality = _PERSONALITIES.get(self.scope_vendor, siglent)
361
+ personality.handle_write(self, command)
362
+
353
363
  def read(self) -> str:
354
364
  """Return an empty response for completeness."""
355
365
  if not self._connected:
@@ -587,59 +597,31 @@ class MockConnection(BaseConnection):
587
597
  return "CV"
588
598
  return "CV"
589
599
 
590
- if upper in {":TRIG:STAT?", "TRIG:STAT?"}:
591
- if len(self.trigger_status) > 1:
592
- return self.trigger_status.pop(0)
593
- return self.trigger_status[0]
594
-
595
- if upper == "TRIG_MODE?":
596
- return self.trigger_mode
600
+ personality = _PERSONALITIES.get(self.scope_vendor, siglent)
601
+ response = personality.handle_query(self, command)
602
+ if response is not None:
603
+ return response
597
604
 
598
- if upper == "TRIG_SELECT?":
599
- return f"{self.trigger_type},SR,{self.trigger_source}"
605
+ if self.psu_mode or self.awg_mode or self.daq_mode:
606
+ return ""
600
607
 
601
- if match := re.match(r"C(\d+):VDIV\?", command, re.IGNORECASE):
602
- channel = int(match.group(1))
603
- value = self._voltage_scales.get(channel, 1.0)
604
- return f"C{channel}:VDIV {_format_scientific(value, 'V')}"
605
-
606
- if match := re.match(r"C(\d+):OFST\?", command, re.IGNORECASE):
607
- channel = int(match.group(1))
608
- value = self._voltage_offsets.get(channel, 0.0)
609
- return f"C{channel}:OFST {_format_scientific(value, 'V')}"
610
-
611
- if match := re.match(r"C(\d+):TRA\?", command, re.IGNORECASE):
612
- channel = int(match.group(1))
613
- return "ON" if self._channel_enabled.get(channel, True) else "OFF"
614
-
615
- if match := re.match(r"C(\d+):TRLV\?", command, re.IGNORECASE):
616
- channel = int(match.group(1))
617
- return f"C{channel}:TRLV {_format_scientific(self.trigger_level.get(channel, 0.0), 'V')}"
618
-
619
- if upper == "TDIV?":
620
- return f"TDIV {_format_scientific(self.timebase, 'S')}"
621
-
622
- if upper == "SARA?":
623
- return f"SARA {_format_scientific(self.sample_rate, 'SA/S')}"
624
-
625
- return ""
608
+ # Real scopes produce no response at all for unknown or wrong-dialect
609
+ # queries - the caller's read times out. Model that honestly.
610
+ raise exceptions.TimeoutError(f"MockConnection ({self.scope_dialect}) has no response for query: {command!r}")
626
611
 
627
612
  def query_many(self, commands: Iterable[str]) -> List[str]:
628
613
  """Convenience helper to query multiple commands sequentially."""
629
614
  return [self.query(cmd) for cmd in commands]
630
615
 
631
- def _build_waveform_block(self, payload: bytes) -> bytes:
632
- """Construct a minimal Siglent-style block response."""
633
- length = len(payload)
634
- length_str = str(length).encode()
635
- header = b"DESC,#" + str(len(length_str)).encode() + length_str
636
- return header + payload
637
-
638
616
  def read_raw(self, size: Optional[int] = None) -> bytes:
639
- """Return deterministic raw waveform data."""
617
+ """Return deterministic raw waveform data (or a mock screenshot BMP after SCDP?)."""
640
618
  if not self._connected:
641
619
  raise exceptions.ConnectionError(f"Not connected to oscilloscope at {self.host}:{self.port}")
642
620
 
643
- channel = self._last_waveform_channel or next(iter(self._waveform_payloads.keys()))
644
- payload = self._waveform_payloads.get(channel, bytes())
645
- return self._build_waveform_block(payload)
621
+ if self.writes and self.writes[-1].upper() == "SCDP?":
622
+ # Bare IEEE 488.2 block (no "DESC," prefix), matching how a real
623
+ # scope's SCDP? reply is parsed in screen_capture.py.
624
+ return _build_ieee_block(MOCK_SCREENSHOT_BMP)
625
+
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")