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.
- scpi_control/__init__.py +1 -1
- scpi_control/automation.py +28 -12
- scpi_control/channel.py +92 -31
- scpi_control/connection/base.py +4 -0
- scpi_control/connection/mock/__init__.py +5 -0
- scpi_control/connection/{mock.py → mock/base.py} +68 -86
- scpi_control/connection/mock/helpers.py +25 -0
- scpi_control/connection/mock/lecroy.py +96 -0
- scpi_control/connection/mock/siglent.py +235 -0
- scpi_control/connection/mock/tektronix.py +226 -0
- scpi_control/connection/socket.py +172 -81
- scpi_control/connection/visa_connection.py +7 -5
- scpi_control/exceptions.py +6 -0
- scpi_control/function_generator.py +4 -4
- scpi_control/gui/app.py +4 -4
- scpi_control/gui/connection_manager.py +7 -7
- scpi_control/gui/main_window.py +15 -19
- scpi_control/gui/widgets/daq_ai_panel.py +4 -12
- scpi_control/gui/widgets/daq_data_view.py +2 -4
- scpi_control/gui/widgets/daq_scan_config.py +4 -8
- scpi_control/gui/widgets/math_panel.py +2 -4
- scpi_control/gui/widgets/psu_control.py +2 -4
- scpi_control/gui/widgets/terminal_widget.py +10 -20
- scpi_control/gui/widgets/timebase_control.py +4 -8
- scpi_control/gui/widgets/vector_graphics_panel.py +2 -2
- scpi_control/math_channel.py +5 -14
- scpi_control/measurement.py +66 -15
- scpi_control/measurement_badges.py +123 -0
- scpi_control/models.py +333 -20
- scpi_control/oscilloscope.py +82 -22
- scpi_control/power_supply.py +5 -5
- scpi_control/report_generator/__init__.py +1 -1
- scpi_control/report_generator/analysis/__init__.py +1 -0
- scpi_control/report_generator/analysis/computed_analyzer.py +137 -0
- scpi_control/report_generator/app.py +74 -3
- scpi_control/report_generator/generators/markdown_generator.py +12 -8
- scpi_control/report_generator/generators/pdf_generator.py +147 -139
- scpi_control/report_generator/llm/analyzer.py +20 -8
- scpi_control/report_generator/llm/client.py +134 -91
- scpi_control/report_generator/llm/context_builder.py +13 -16
- scpi_control/report_generator/llm/daq_analyzer.py +21 -16
- scpi_control/report_generator/llm/prompts.py +17 -1
- scpi_control/report_generator/llm/tools.py +274 -0
- scpi_control/report_generator/main_window.py +37 -40
- scpi_control/report_generator/models/report_data.py +48 -21
- scpi_control/report_generator/models/template.py +26 -0
- scpi_control/report_generator/utils/waveform_analyzer.py +96 -25
- scpi_control/report_generator/utils/waveform_loader.py +271 -201
- scpi_control/report_generator/widgets/ai_analysis_panel.py +22 -3
- scpi_control/report_generator/widgets/metadata_panel.py +87 -1
- scpi_control/report_generator/widgets/pdf_preview_dialog.py +5 -2
- scpi_control/report_generator/widgets/template_manager_dialog.py +29 -0
- scpi_control/scpi_commands.py +744 -75
- scpi_control/server/__init__.py +0 -0
- scpi_control/server/__main__.py +19 -0
- scpi_control/server/api/__init__.py +0 -0
- scpi_control/server/api/discovery.py +35 -0
- scpi_control/server/api/scope.py +467 -0
- scpi_control/server/api/sessions.py +50 -0
- scpi_control/server/api/stream.py +103 -0
- scpi_control/server/app.py +91 -0
- scpi_control/server/compute.py +109 -0
- scpi_control/server/discovery.py +126 -0
- scpi_control/server/recorder.py +66 -0
- scpi_control/server/schemas.py +113 -0
- scpi_control/server/sessions.py +392 -0
- scpi_control/server/static/assets/index-0Vpguwg6.js +9 -0
- scpi_control/server/static/assets/index-CyUPLIHk.css +1 -0
- scpi_control/server/static/index.html +13 -0
- scpi_control/trigger.py +138 -51
- scpi_control/vector_graphics.py +3 -2
- scpi_control/waveform.py +77 -206
- scpi_control/waveform_schema.py +44 -0
- scpi_control/waveform_transfer.py +370 -0
- {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/METADATA +87 -21
- scpi_instrument_control-3.0.0.dist-info/RECORD +144 -0
- {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/WHEEL +1 -1
- {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/entry_points.txt +1 -0
- {scpi_instrument_control-1.1.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/top_level.txt +0 -1
- scpi_instrument_control-1.1.0.dist-info/RECORD +0 -119
- siglent/__init__.py +0 -35
- siglent/exceptions.py +0 -40
- {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__ = "
|
|
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/automation.py
CHANGED
|
@@ -63,19 +63,21 @@ class DataCollector:
|
|
|
63
63
|
def __init__(
|
|
64
64
|
self,
|
|
65
65
|
host: str,
|
|
66
|
-
port: int =
|
|
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 =
|
|
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
|
-
#
|
|
530
|
-
|
|
531
|
-
self.collector.scope.
|
|
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
|
-
|
|
536
|
-
status = self.collector.scope.query(":TRIG:STAT?").strip()
|
|
552
|
+
status = self.collector.scope.acquisition_status()
|
|
537
553
|
|
|
538
|
-
if status
|
|
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
|
-
|
|
36
|
-
|
|
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(
|
|
46
|
-
# Response format: "C1:TRA ON"
|
|
47
|
-
#
|
|
48
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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.
|
|
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("
|
|
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
|
-
|
|
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."""
|
scpi_control/connection/base.py
CHANGED
|
@@ -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:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Mock connection
|
|
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
|
-
|
|
13
|
-
""
|
|
14
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
return
|
|
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
|
|
599
|
-
return
|
|
605
|
+
if self.psu_mode or self.awg_mode or self.daq_mode:
|
|
606
|
+
return ""
|
|
600
607
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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")
|