SCPI-Instrument-Control 1.1.0__py3-none-any.whl → 2.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 (50) hide show
  1. scpi_control/__init__.py +1 -1
  2. scpi_control/automation.py +28 -12
  3. scpi_control/channel.py +30 -18
  4. scpi_control/connection/base.py +4 -0
  5. scpi_control/connection/mock.py +206 -45
  6. scpi_control/connection/socket.py +172 -81
  7. scpi_control/connection/visa_connection.py +7 -5
  8. scpi_control/function_generator.py +4 -4
  9. scpi_control/gui/app.py +4 -4
  10. scpi_control/gui/connection_manager.py +7 -7
  11. scpi_control/gui/main_window.py +15 -19
  12. scpi_control/gui/widgets/daq_ai_panel.py +2 -4
  13. scpi_control/gui/widgets/daq_data_view.py +2 -4
  14. scpi_control/gui/widgets/daq_scan_config.py +4 -8
  15. scpi_control/gui/widgets/math_panel.py +2 -4
  16. scpi_control/gui/widgets/psu_control.py +2 -4
  17. scpi_control/gui/widgets/terminal_widget.py +10 -20
  18. scpi_control/gui/widgets/timebase_control.py +4 -8
  19. scpi_control/gui/widgets/vector_graphics_panel.py +2 -2
  20. scpi_control/models.py +21 -0
  21. scpi_control/oscilloscope.py +51 -21
  22. scpi_control/power_supply.py +5 -5
  23. scpi_control/scpi_commands.py +203 -70
  24. scpi_control/server/__init__.py +0 -0
  25. scpi_control/server/__main__.py +19 -0
  26. scpi_control/server/api/__init__.py +0 -0
  27. scpi_control/server/api/discovery.py +35 -0
  28. scpi_control/server/api/scope.py +467 -0
  29. scpi_control/server/api/sessions.py +50 -0
  30. scpi_control/server/api/stream.py +103 -0
  31. scpi_control/server/app.py +91 -0
  32. scpi_control/server/compute.py +109 -0
  33. scpi_control/server/discovery.py +126 -0
  34. scpi_control/server/recorder.py +66 -0
  35. scpi_control/server/schemas.py +113 -0
  36. scpi_control/server/sessions.py +392 -0
  37. scpi_control/server/static/assets/index-0Vpguwg6.js +9 -0
  38. scpi_control/server/static/assets/index-CyUPLIHk.css +1 -0
  39. scpi_control/server/static/index.html +13 -0
  40. scpi_control/trigger.py +88 -44
  41. scpi_control/vector_graphics.py +3 -2
  42. scpi_control/waveform.py +27 -9
  43. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-2.0.0.dist-info}/METADATA +59 -18
  44. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-2.0.0.dist-info}/RECORD +48 -34
  45. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-2.0.0.dist-info}/WHEEL +1 -1
  46. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-2.0.0.dist-info}/entry_points.txt +1 -0
  47. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-2.0.0.dist-info}/top_level.txt +0 -1
  48. siglent/__init__.py +0 -35
  49. siglent/exceptions.py +0 -40
  50. {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-2.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__ = "2.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,7 @@ 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
8
 
8
9
  if TYPE_CHECKING:
9
10
  from scpi_control.oscilloscope import Oscilloscope
@@ -35,6 +36,13 @@ class Channel:
35
36
  if not 1 <= channel_number <= 4:
36
37
  raise exceptions.InvalidParameterError(f"Invalid channel number: {channel_number}. Must be 1-4.")
37
38
 
39
+ @property
40
+ def _dialect(self) -> str:
41
+ return getattr(self._scope, "dialect", None) or "legacy"
42
+
43
+ def _cmd(self, name: str, **kwargs) -> str:
44
+ return self._scope._get_command(name, **kwargs)
45
+
38
46
  @property
39
47
  def enabled(self) -> bool:
40
48
  """Get channel display state.
@@ -42,7 +50,7 @@ class Channel:
42
50
  Returns:
43
51
  True if channel is displayed, False otherwise
44
52
  """
45
- response = self._scope.query(f"{self._prefix}:TRA?")
53
+ response = self._scope.query(self._cmd("get_channel_display", ch=self._channel))
46
54
  # Response format: "C1:TRA ON" or "C1:TRA OFF"
47
55
  # Extract the last word
48
56
  return "ON" in response.upper()
@@ -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,7 +171,7 @@ 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
+ response = self._scope.query(self._cmd("get_probe_ratio", ch=self._channel))
168
175
  # Response may include echo like "C1:ATTN 10"
169
176
  if ":" in response:
170
177
  response = response.split(":", 1)[1]
@@ -183,7 +190,7 @@ class Channel:
183
190
  """
184
191
  if ratio <= 0:
185
192
  raise exceptions.InvalidParameterError(f"Probe ratio must be positive: {ratio}")
186
- self._scope.write(f"{self._prefix}:ATTN {ratio}")
193
+ self._scope.write(self._cmd("set_probe_ratio", ch=self._channel, ratio=ratio))
187
194
  logger.info(f"Channel {self._channel} probe ratio set to {ratio}X")
188
195
 
189
196
  @property
@@ -193,8 +200,11 @@ class Channel:
193
200
  Returns:
194
201
  Bandwidth limit: 'ON', 'OFF', or frequency limit
195
202
  """
196
- response = self._scope.query(f"{self._prefix}:BWL?")
197
- return response.upper().strip()
203
+ 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
206
+ return "OFF" if response == "FULL" else "ON"
207
+ return response
198
208
 
199
209
  @bandwidth_limit.setter
200
210
  def bandwidth_limit(self, limit: BandwidthLimitType) -> None:
@@ -206,10 +216,11 @@ class Channel:
206
216
  limit = limit.upper()
207
217
  if limit not in ["ON", "OFF", "FULL"]:
208
218
  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}")
219
+ if self._dialect == "modern":
220
+ wire = "FULL" if limit in ("OFF", "FULL") else "20M"
221
+ else:
222
+ wire = "OFF" if limit == "FULL" else limit
223
+ self._scope.write(self._cmd("set_bandwidth_limit", ch=self._channel, limit=wire))
213
224
  logger.info(f"Channel {self._channel} bandwidth limit set to {limit}")
214
225
 
215
226
  @property
@@ -219,6 +230,7 @@ class Channel:
219
230
  Returns:
220
231
  Unit string (typically 'V' for volts)
221
232
  """
233
+ # legacy-only command; not routed
222
234
  response = self._scope.query(f"{self._prefix}:UNIT?")
223
235
  return response.strip()
224
236
 
@@ -241,7 +253,7 @@ class Channel:
241
253
  # For per-channel auto-scale, we might need to use different commands
242
254
  # This is a basic implementation that may need adjustment for SD824x
243
255
  logger.info(f"Auto-scaling channel {self._channel}")
244
- self._scope.write("ASET")
256
+ self._scope.write(self._cmd("auto_setup"))
245
257
 
246
258
  def get_configuration(self) -> dict:
247
259
  """Get all channel configuration parameters.
@@ -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:
@@ -3,10 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- from typing import Dict, Iterable, List, Optional, Union
6
+ from typing import Dict, Iterable, List, Optional, Tuple, Union
7
7
 
8
8
  from scpi_control import exceptions
9
9
  from scpi_control.connection.base import BaseConnection
10
+ from scpi_control.models import detect_model_from_idn
10
11
 
11
12
 
12
13
  def _format_scientific(value: float, unit: str) -> str:
@@ -14,6 +15,44 @@ def _format_scientific(value: float, unit: str) -> str:
14
15
  return f"{value:.2E}{unit}"
15
16
 
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", "%"),
53
+ }
54
+
55
+
17
56
  class MockConnection(BaseConnection):
18
57
  """Mock connection that returns deterministic SCPI responses.
19
58
 
@@ -54,16 +93,26 @@ class MockConnection(BaseConnection):
54
93
  channels = channel_states.keys() if channel_states else range(1, 3)
55
94
 
56
95
  self.idn = idn
96
+ # Personality: answer only the wire dialect this model actually speaks
97
+ self.scope_dialect = detect_model_from_idn(idn).dialect
57
98
  self._channel_enabled: Dict[int, bool] = {ch: channel_states.get(ch, True) if channel_states else True for ch in channels}
58
99
  self._voltage_scales: Dict[int, float] = {ch: voltage_scales.get(ch, 1.0) if voltage_scales else 1.0 for ch in channels}
59
100
  self._voltage_offsets: Dict[int, float] = {ch: voltage_offsets.get(ch, 0.0) if voltage_offsets else 0.0 for ch in channels}
60
101
  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}
102
+ self._channel_coupling: Dict[int, str] = {ch: "D1M" for ch in channels}
61
103
 
62
104
  self.sample_rate = sample_rate
63
105
  self.timebase = timebase
64
- self.trigger_mode = "STOP"
65
106
  self.trigger_type = "EDGE"
66
107
  self.trigger_source = "C1"
108
+ if self.scope_dialect == "modern":
109
+ # Initial wire tokens must be modern vocabulary (guide p.482, p.494)
110
+ self.trigger_mode = "AUTO"
111
+ self.trigger_slope = "RISing"
112
+ else:
113
+ self.trigger_mode = "STOP"
114
+ self.trigger_slope = "POS"
115
+ self.trigger_coupling = "DC"
67
116
  self.trigger_level: Dict[int, float] = {ch: 0.0 for ch in channels}
68
117
  self.trigger_status: List[str] = trigger_status[:] if trigger_status else ["Stop"]
69
118
 
@@ -312,6 +361,56 @@ class MockConnection(BaseConnection):
312
361
  return
313
362
 
314
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
+
315
414
  if command.upper().startswith("TDIV "):
316
415
  value = command.split(" ", 1)[1]
317
416
  try:
@@ -331,6 +430,8 @@ class MockConnection(BaseConnection):
331
430
  elif match := re.match(r"C(\d+):TRA\s+(ON|OFF)", command, re.IGNORECASE):
332
431
  channel = int(match.group(1))
333
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()
334
435
  elif command.upper().startswith("TRIG_MODE "):
335
436
  self.trigger_mode = command.split(" ", 1)[1].upper()
336
437
  elif command.upper().startswith("TRIG_SELECT "):
@@ -338,10 +439,15 @@ class MockConnection(BaseConnection):
338
439
  trig_type, _, source = params.split(",")
339
440
  self.trigger_type = trig_type.strip().upper()
340
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()
341
446
  elif command.upper() == "ARM":
342
- # Simulate an acquisition that will eventually stop when no custom sequence is provided
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.
343
449
  if len(self.trigger_status) <= 1:
344
- self.trigger_status = ["Run", "Stop"]
450
+ self.trigger_status = ["Ready", "Stop"]
345
451
  elif match := re.match(r"C(\d+):TRLV\s+(.+)", command, re.IGNORECASE):
346
452
  channel = int(match.group(1))
347
453
  self.trigger_level[channel] = float(match.group(2))
@@ -587,42 +693,95 @@ class MockConnection(BaseConnection):
587
693
  return "CV"
588
694
  return "CV"
589
695
 
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
597
-
598
- if upper == "TRIG_SELECT?":
599
- return f"{self.trigger_type},SR,{self.trigger_source}"
600
-
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 ""
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}"
778
+
779
+ if self.psu_mode or self.awg_mode or self.daq_mode:
780
+ return ""
781
+
782
+ # Real scopes produce no response at all for unknown or wrong-dialect
783
+ # queries - the caller's read times out. Model that honestly.
784
+ raise exceptions.TimeoutError(f"MockConnection ({self.scope_dialect}) has no response for query: {command!r}")
626
785
 
627
786
  def query_many(self, commands: Iterable[str]) -> List[str]:
628
787
  """Convenience helper to query multiple commands sequentially."""
@@ -630,16 +789,18 @@ class MockConnection(BaseConnection):
630
789
 
631
790
  def _build_waveform_block(self, payload: bytes) -> bytes:
632
791
  """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
792
+ return b"DESC," + _build_ieee_block(payload)
637
793
 
638
794
  def read_raw(self, size: Optional[int] = None) -> bytes:
639
- """Return deterministic raw waveform data."""
795
+ """Return deterministic raw waveform data (or a mock screenshot BMP after SCDP?)."""
640
796
  if not self._connected:
641
797
  raise exceptions.ConnectionError(f"Not connected to oscilloscope at {self.host}:{self.port}")
642
798
 
799
+ if self.writes and self.writes[-1].upper() == "SCDP?":
800
+ # Bare IEEE 488.2 block (no "DESC," prefix), matching how a real
801
+ # scope's SCDP? reply is parsed in screen_capture.py.
802
+ return _build_ieee_block(MOCK_SCREENSHOT_BMP)
803
+
643
804
  channel = self._last_waveform_channel or next(iter(self._waveform_payloads.keys()))
644
805
  payload = self._waveform_payloads.get(channel, bytes())
645
806
  return self._build_waveform_block(payload)