rf-bench 0.2.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.
rf_bench/__init__.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ rf_bench — Python drivers and RF utilities for bench instrument automation
3
+
4
+ Drivers connect via raw TCP/SCPI (Siglent instruments, port 5025) or Hamlib
5
+ rigctld (radios, port 4532). No NI-VISA or pyvisa required.
6
+
7
+ Subpackages::
8
+
9
+ rf_bench.siglent — SSA3000X, SDG1000X, SDS2000X, SDM3000X, SPD3303X
10
+ rf_bench.icom — IC7300
11
+ rf_bench.yaesu — FT891
12
+ rf_bench.utils — RF math: conversions, noise, impedance, propagation,
13
+ passive components, attenuators, IM products, S-meter
14
+
15
+ All public symbols are re-exported here for convenience::
16
+
17
+ from rf_bench import SDG1000X, SPD3303X, IC7300, FT891, dbm_to_vpp, format_freq
18
+
19
+ Or import from subpackages directly::
20
+
21
+ from rf_bench.siglent import SSA3000X, SDG1000X, SDM3000X, SPD3303X
22
+ from rf_bench.icom import IC7300
23
+ from rf_bench.utils import thermal_noise_floor, cascaded_noise_figure, pi_attenuator
24
+ """
25
+
26
+ # Instrument classes
27
+ from .siglent import (
28
+ SSA3000X, SDG1000X, SDS2000X, DBM_MIN, DBM_MAX,
29
+ SDM3000X, SDM_RANGE_AUTO,
30
+ SPD3303X, TRACKING_INDEPENDENT, TRACKING_SERIES, TRACKING_PARALLEL,
31
+ )
32
+ from .icom import IC7300
33
+ from .yaesu import FT891, PREAMP_OFF, PREAMP_AMP1
34
+
35
+ # RF utilities — re-exported flat for convenience
36
+ from .utils import (
37
+ # Constants
38
+ SPEED_OF_LIGHT, S9_HF_DBM, S9_VHF_DBM,
39
+ # Power / voltage
40
+ dbm_to_vpp, vpp_to_dbm,
41
+ dbm_to_vrms, vrms_to_dbm,
42
+ dbm_to_watts, watts_to_dbm,
43
+ dbm_to_uv, uv_to_dbm,
44
+ # Power ratio / extended dB
45
+ db_to_linear, linear_to_db,
46
+ db_to_voltage_ratio, voltage_ratio_to_db,
47
+ dbm_to_dbw, dbw_to_dbm,
48
+ dbm_to_dbuv, dbuv_to_dbm,
49
+ # Impedance / reflection
50
+ rl_to_vswr, vswr_to_rl,
51
+ gamma_to_vswr, vswr_to_gamma,
52
+ rl_to_gamma, gamma_to_rl,
53
+ rl_to_vswr_v, vswr_to_rl_v, gamma_to_vswr_v,
54
+ # Noise and dynamic range
55
+ thermal_noise_floor,
56
+ noise_figure_from_mds, mds_from_noise_figure,
57
+ ip3_from_imd, ip3_to_dynamic_range,
58
+ cascaded_noise_figure,
59
+ noise_temp_to_nf, nf_to_noise_temp,
60
+ # Propagation / antenna
61
+ wavelength, quarter_wave, half_wave,
62
+ freespace_path_loss,
63
+ # Passive components
64
+ capacitive_reactance, inductive_reactance,
65
+ lc_resonant_freq, l_from_resonant, c_from_resonant,
66
+ q_factor, bw_from_q,
67
+ parallel_resistance, voltage_divider,
68
+ skin_depth,
69
+ # Attenuator design
70
+ pi_attenuator, t_attenuator,
71
+ # IM products
72
+ intermod_products,
73
+ # S-meter
74
+ s_unit_to_dbm, dbm_to_s_unit,
75
+ # Formatting
76
+ format_freq, format_freq_short,
77
+ # Standard value series
78
+ nearest_rbw, nearest_value,
79
+ SIGLENT_RBW_SERIES, E12_SERIES, E24_SERIES, E48_SERIES, E96_SERIES,
80
+ )
81
+
82
+ __version__ = "0.2.0"
83
+
84
+ __all__ = [
85
+ # Siglent instruments
86
+ "SSA3000X",
87
+ "SDG1000X", "DBM_MIN", "DBM_MAX",
88
+ "SDS2000X",
89
+ "SDM3000X", "SDM_RANGE_AUTO",
90
+ "SPD3303X", "TRACKING_INDEPENDENT", "TRACKING_SERIES", "TRACKING_PARALLEL",
91
+ # Icom
92
+ "IC7300",
93
+ # Yaesu
94
+ "FT891", "PREAMP_OFF", "PREAMP_AMP1",
95
+ # Constants
96
+ "SPEED_OF_LIGHT", "S9_HF_DBM", "S9_VHF_DBM",
97
+ # Power / voltage
98
+ "dbm_to_vpp", "vpp_to_dbm",
99
+ "dbm_to_vrms", "vrms_to_dbm",
100
+ "dbm_to_watts", "watts_to_dbm",
101
+ "dbm_to_uv", "uv_to_dbm",
102
+ # Power ratio / extended dB
103
+ "db_to_linear", "linear_to_db",
104
+ "db_to_voltage_ratio", "voltage_ratio_to_db",
105
+ "dbm_to_dbw", "dbw_to_dbm",
106
+ "dbm_to_dbuv", "dbuv_to_dbm",
107
+ # Impedance / reflection
108
+ "rl_to_vswr", "vswr_to_rl",
109
+ "gamma_to_vswr", "vswr_to_gamma",
110
+ "rl_to_gamma", "gamma_to_rl",
111
+ "rl_to_vswr_v", "vswr_to_rl_v", "gamma_to_vswr_v",
112
+ # Noise and dynamic range
113
+ "thermal_noise_floor",
114
+ "noise_figure_from_mds", "mds_from_noise_figure",
115
+ "ip3_from_imd", "ip3_to_dynamic_range",
116
+ "cascaded_noise_figure",
117
+ "noise_temp_to_nf", "nf_to_noise_temp",
118
+ # Propagation / antenna
119
+ "wavelength", "quarter_wave", "half_wave",
120
+ "freespace_path_loss",
121
+ # Passive components
122
+ "capacitive_reactance", "inductive_reactance",
123
+ "lc_resonant_freq", "l_from_resonant", "c_from_resonant",
124
+ "q_factor", "bw_from_q",
125
+ "parallel_resistance", "voltage_divider",
126
+ "skin_depth",
127
+ # Attenuator design
128
+ "pi_attenuator", "t_attenuator",
129
+ # IM products
130
+ "intermod_products",
131
+ # S-meter
132
+ "s_unit_to_dbm", "dbm_to_s_unit",
133
+ # Formatting
134
+ "format_freq", "format_freq_short",
135
+ # Standard value series
136
+ "nearest_rbw", "nearest_value",
137
+ "SIGLENT_RBW_SERIES", "E12_SERIES", "E24_SERIES", "E48_SERIES", "E96_SERIES",
138
+ ]
@@ -0,0 +1,5 @@
1
+ """rf_bench.icom — Icom transceiver drivers via Hamlib rigctld"""
2
+
3
+ from .ic7300 import IC7300
4
+
5
+ __all__ = ["IC7300"]
@@ -0,0 +1,213 @@
1
+ """
2
+ ic7300.py — Icom IC-7300 CAT control via Hamlib rigctld
3
+
4
+ Communicates with rigctld over a plain TCP socket on port 4532.
5
+ rigctld must be running before any test is started:
6
+
7
+ rigctld -m 3073 -r /dev/ttyUSB0 -s 115200 &
8
+
9
+ Hamlib model numbers: 3073 (Hamlib 4.x), 373 (Hamlib 3.x).
10
+
11
+ The IC-7300 CI-V baud rate must match (Menu → Set → Connectors → CI-V Baud Rate).
12
+ """
13
+
14
+ import socket
15
+ import time
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Constants
20
+ # ---------------------------------------------------------------------------
21
+
22
+ DEFAULT_HOST = "localhost"
23
+ DEFAULT_PORT = 4532
24
+ CONNECT_TIMEOUT = 5 # seconds
25
+ RECV_TIMEOUT = 3 # seconds
26
+ RECV_BUFSIZE = 4096
27
+
28
+ # AGC mode values as sent to rigctld set_level AGC
29
+ AGC_OFF = 0
30
+ AGC_FAST = 1
31
+ AGC_MID = 2 # "medium" on IC-7300 front panel
32
+ AGC_SLOW = 3
33
+
34
+ # Mode strings understood by Hamlib
35
+ MODES = {"usb": "USB", "lsb": "LSB", "cw": "CW", "cwr": "CWR",
36
+ "am": "AM", "fm": "FM", "rtty": "RTTY"}
37
+
38
+ # Standard passband widths (Hz) by mode — used as default if not specified
39
+ DEFAULT_PASSBAND = {"USB": 2400, "LSB": 2400, "CW": 500, "CWR": 500,
40
+ "AM": 6000, "FM": 15000, "RTTY": 500}
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Driver class
45
+ # ---------------------------------------------------------------------------
46
+
47
+ class IC7300:
48
+ """
49
+ IC-7300 CAT driver via Hamlib rigctld.
50
+
51
+ All frequency values are in Hz. get_strength() returns a numeric value
52
+ whose scale depends on the Hamlib version and calibration; use the
53
+ smeter-cal test to map it to dBm.
54
+
55
+ Usage:
56
+ rig = IC7300()
57
+ rig.set_mode("usb")
58
+ rig.set_frequency(14_200_000)
59
+ rig.set_agc("off")
60
+ strength = rig.get_strength()
61
+ rig.close()
62
+ """
63
+
64
+ def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
65
+ self._host = host
66
+ self._port = port
67
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
68
+ self._sock.settimeout(CONNECT_TIMEOUT)
69
+ self._sock.connect((host, port))
70
+ self._sock.settimeout(RECV_TIMEOUT)
71
+
72
+ # ------------------------------------------------------------------
73
+ # Public API
74
+ # ------------------------------------------------------------------
75
+
76
+ def get_frequency(self) -> float:
77
+ """Return current VFO-A frequency in Hz."""
78
+ resp = self._cmd("\\get_freq")
79
+ try:
80
+ # rigctld responds with just the number, or "Frequency: <n>"
81
+ return float(resp.split()[-1])
82
+ except (ValueError, IndexError):
83
+ return 0.0
84
+
85
+ def set_frequency(self, freq_hz: float) -> None:
86
+ """Set VFO-A frequency in Hz."""
87
+ self._cmd(f"\\set_freq {int(freq_hz)}")
88
+ time.sleep(0.1) # give the IC-7300 time to tune
89
+
90
+ def get_mode(self) -> tuple[str, int]:
91
+ """
92
+ Return current (mode_string, passband_hz).
93
+ Mode string is a Hamlib mode name e.g. "USB", "CW".
94
+ """
95
+ resp = self._cmd("\\get_mode")
96
+ lines = resp.strip().splitlines()
97
+ mode = lines[0].strip() if lines else "?"
98
+ try:
99
+ passband = int(lines[1].strip()) if len(lines) > 1 else 0
100
+ except ValueError:
101
+ passband = 0
102
+ return mode, passband
103
+
104
+ def set_mode(self, mode: str, passband_hz: int = 0) -> None:
105
+ """
106
+ Set receiver mode.
107
+
108
+ Args:
109
+ mode: One of 'usb', 'lsb', 'cw', 'cwr', 'am', 'fm', 'rtty'
110
+ passband_hz: IF filter width in Hz. 0 = use rig default.
111
+ """
112
+ ham_mode = MODES.get(mode.lower(), mode.upper())
113
+ if passband_hz == 0:
114
+ passband_hz = DEFAULT_PASSBAND.get(ham_mode, 0)
115
+ self._cmd(f"\\set_mode {ham_mode} {passband_hz}")
116
+ time.sleep(0.15)
117
+
118
+ def get_strength(self) -> float:
119
+ """
120
+ Return the S-meter / signal strength level.
121
+
122
+ Hamlib returns STRENGTH as a float. For the IC-7300 via Hamlib 4.x
123
+ this is typically in dB relative to S9 (range ≈ −54 to +60 dB).
124
+ The exact mapping depends on the Hamlib version; use smeter-cal to
125
+ characterize the specific rig/version combination.
126
+
127
+ Returns NaN if the read fails.
128
+ """
129
+ resp = self._cmd("\\get_level STRENGTH")
130
+ try:
131
+ return float(resp.split()[-1])
132
+ except (ValueError, IndexError):
133
+ return float("nan")
134
+
135
+ def get_strength_settled(self, settle_s: float = 0.5, samples: int = 3) -> float:
136
+ """
137
+ Wait settle_s seconds then take the average of `samples` strength readings.
138
+
139
+ Use after changing SDG level to let the IC-7300's AGC settle before reading.
140
+ """
141
+ time.sleep(settle_s)
142
+ readings = []
143
+ for _ in range(samples):
144
+ v = self.get_strength()
145
+ if not float("nan") == v: # isnan check
146
+ readings.append(v)
147
+ time.sleep(0.1)
148
+ if not readings:
149
+ return float("nan")
150
+ return sum(readings) / len(readings)
151
+
152
+ def set_agc(self, mode: str) -> None:
153
+ """
154
+ Set AGC mode.
155
+
156
+ Args:
157
+ mode: 'off', 'fast', 'mid', or 'slow'
158
+ """
159
+ agc_map = {"off": AGC_OFF, "fast": AGC_FAST, "mid": AGC_MID, "slow": AGC_SLOW}
160
+ val = agc_map.get(mode.lower())
161
+ if val is None:
162
+ raise ValueError(f"AGC mode must be one of {list(agc_map)}, got {mode!r}")
163
+ self._cmd(f"\\set_level AGC {val}")
164
+ time.sleep(0.1)
165
+
166
+ def get_agc(self) -> int:
167
+ """Return current AGC value (0=off, 1=fast, 2=mid, 3=slow)."""
168
+ resp = self._cmd("\\get_level AGC")
169
+ try:
170
+ return int(float(resp.split()[-1]))
171
+ except (ValueError, IndexError):
172
+ return -1
173
+
174
+ def set_rf_gain(self, gain: float) -> None:
175
+ """
176
+ Set RF gain (0.0 – 1.0, where 1.0 = maximum gain).
177
+ With AGC off, this directly controls receiver sensitivity.
178
+ """
179
+ self._cmd(f"\\set_level RFGAIN {gain:.3f}")
180
+
181
+ def close(self) -> None:
182
+ """Close the rigctld connection."""
183
+ self._sock.close()
184
+
185
+ # ------------------------------------------------------------------
186
+ # Context manager
187
+ # ------------------------------------------------------------------
188
+
189
+ def __enter__(self):
190
+ return self
191
+
192
+ def __exit__(self, *_):
193
+ self.close()
194
+
195
+ # ------------------------------------------------------------------
196
+ # Internal helpers
197
+ # ------------------------------------------------------------------
198
+
199
+ def _cmd(self, cmd: str) -> str:
200
+ """Send a command to rigctld and return the response."""
201
+ self._sock.sendall((cmd + "\n").encode())
202
+ time.sleep(0.05)
203
+ try:
204
+ resp = b""
205
+ while True:
206
+ chunk = self._sock.recv(RECV_BUFSIZE)
207
+ resp += chunk
208
+ # rigctld responses end with a newline (or blank line for multi-line)
209
+ if resp.endswith(b"\n"):
210
+ break
211
+ return resp.decode(errors="replace").strip()
212
+ except socket.timeout:
213
+ return ""
@@ -0,0 +1,23 @@
1
+ """rf_bench.siglent — Siglent instrument drivers (SCPI over raw TCP, port 5025)"""
2
+
3
+ from .ssa3000x import SSA3000X
4
+ from .sdg1000x import SDG1000X, DBM_MIN, DBM_MAX
5
+ from .sds2000x import SDS2000X
6
+ from .sdm3000x import SDM3000X, RANGE_AUTO as SDM_RANGE_AUTO
7
+ from .spd3303x import (
8
+ SPD3303X,
9
+ TRACKING_INDEPENDENT, TRACKING_SERIES, TRACKING_PARALLEL,
10
+ )
11
+
12
+ __all__ = [
13
+ # Spectrum analyzer
14
+ "SSA3000X",
15
+ # Function generator
16
+ "SDG1000X", "DBM_MIN", "DBM_MAX",
17
+ # Oscilloscope
18
+ "SDS2000X",
19
+ # Bench multimeter
20
+ "SDM3000X", "SDM_RANGE_AUTO",
21
+ # Triple-output power supply
22
+ "SPD3303X", "TRACKING_INDEPENDENT", "TRACKING_SERIES", "TRACKING_PARALLEL",
23
+ ]
@@ -0,0 +1,220 @@
1
+ """
2
+ sdg1000x.py — Siglent SDG1000X function generator driver
3
+
4
+ Connects via raw TCP/SCPI to port 5025. Uses Siglent's EasyWave protocol
5
+ (C1:BSWV / C2:BSWV commands), not standard IEEE SCPI. Both channels are
6
+ independent; each can be set to different frequencies and levels simultaneously.
7
+
8
+ Model: Siglent SDG1000X series
9
+ Default address: 10.1.1.61:5025
10
+
11
+ Amplitude notes:
12
+ All public methods accept and return dBm (into 50 Ω).
13
+ Internally the SDG uses Vpp across its configured load impedance.
14
+ With LOAD,50 the displayed/set amplitude IS the voltage across the 50 Ω load.
15
+ Conversion is handled by rf_utils.dbm_to_vpp / vpp_to_dbm.
16
+
17
+ Known firmware behavior:
18
+ Some SDG firmware versions ignore the LOAD parameter inside BSWV and require
19
+ a separate `C1:OUTP ON,LOAD,50` to set the termination. Always enable output
20
+ with the LOAD parameter explicit (output_on() handles this).
21
+ """
22
+
23
+ import socket
24
+ import time
25
+
26
+ from ..utils.rf_utils import dbm_to_vpp, vpp_to_dbm
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Constants
31
+ # ---------------------------------------------------------------------------
32
+
33
+ DEFAULT_HOST = "10.1.1.61"
34
+ DEFAULT_PORT = 5025
35
+ CONNECT_TIMEOUT = 10 # seconds
36
+ RECV_TIMEOUT = 5 # seconds
37
+ RECV_BUFSIZE = 65536
38
+
39
+ # SDG1000X amplitude limits (Vpp into 50 Ω)
40
+ VPP_MIN_50 = 0.002 # 2 mVpp ≈ −46 dBm; waveform quality degrades below ~10 mVpp
41
+ VPP_MAX_50 = 10.0 # 10 Vpp ≈ +24 dBm
42
+
43
+ DBM_MIN = vpp_to_dbm(VPP_MIN_50) # ≈ −46 dBm
44
+ DBM_MAX = vpp_to_dbm(VPP_MAX_50) # ≈ +24 dBm
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Driver class
49
+ # ---------------------------------------------------------------------------
50
+
51
+ class SDG1000X:
52
+ """
53
+ Driver for the Siglent SDG1000X dual-channel function generator.
54
+
55
+ Usage:
56
+ sdg = SDG1000X("10.1.1.61")
57
+ sdg.set_sine(1, freq_hz=14_001_000, level_dbm=-20)
58
+ sdg.set_sine(2, freq_hz=14_001_500, level_dbm=-20)
59
+ sdg.output_on(1)
60
+ sdg.output_on(2)
61
+ ...
62
+ sdg.output_off_all()
63
+ sdg.close()
64
+
65
+ Context manager:
66
+ with SDG1000X("10.1.1.61") as sdg:
67
+ sdg.set_sine(1, 14e6, -20)
68
+ sdg.output_on(1)
69
+ ...
70
+ """
71
+
72
+ def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
73
+ self._host = host
74
+ self._port = port
75
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
76
+ self._sock.settimeout(CONNECT_TIMEOUT)
77
+ self._sock.connect((host, port))
78
+ self._sock.settimeout(RECV_TIMEOUT)
79
+ # Drain any startup banner
80
+ time.sleep(0.1)
81
+ try:
82
+ self._sock.recv(RECV_BUFSIZE)
83
+ except socket.timeout:
84
+ pass
85
+
86
+ # ------------------------------------------------------------------
87
+ # Public API
88
+ # ------------------------------------------------------------------
89
+
90
+ def identify(self) -> str:
91
+ """Return IDN string."""
92
+ return self._query("*IDN?")
93
+
94
+ def set_sine(self, channel: int, freq_hz: float, level_dbm: float,
95
+ phase_deg: float = 0.0) -> None:
96
+ """
97
+ Configure a channel for sine wave output.
98
+
99
+ Args:
100
+ channel: 1 or 2
101
+ freq_hz: Frequency in Hz (1 μHz to 60 MHz)
102
+ level_dbm: Output level in dBm into 50 Ω
103
+ phase_deg: Phase offset in degrees [default 0]
104
+
105
+ The channel output is NOT enabled by this call; follow with output_on().
106
+ """
107
+ self._check_channel(channel)
108
+ self._check_level(level_dbm)
109
+
110
+ vpp = dbm_to_vpp(level_dbm)
111
+ ch = f"C{channel}"
112
+ self._cmd(
113
+ f"{ch}:BSWV WVTP,SINE,"
114
+ f"FRQ,{freq_hz:.6f},"
115
+ f"AMP,{vpp:.6f},"
116
+ f"OFST,0,"
117
+ f"PHSE,{phase_deg:.3f}"
118
+ )
119
+
120
+ def output_on(self, channel: int) -> None:
121
+ """Enable output on the specified channel with 50 Ω termination."""
122
+ self._check_channel(channel)
123
+ self._cmd(f"C{channel}:OUTP ON,LOAD,50")
124
+
125
+ def output_off(self, channel: int) -> None:
126
+ """Disable output on the specified channel."""
127
+ self._check_channel(channel)
128
+ self._cmd(f"C{channel}:OUTP OFF")
129
+
130
+ def output_off_all(self) -> None:
131
+ """Disable both channel outputs."""
132
+ self.output_off(1)
133
+ self.output_off(2)
134
+
135
+ def set_level(self, channel: int, level_dbm: float) -> None:
136
+ """
137
+ Change only the output level of a configured channel (preserves frequency).
138
+ """
139
+ self._check_channel(channel)
140
+ self._check_level(level_dbm)
141
+ vpp = dbm_to_vpp(level_dbm)
142
+ self._cmd(f"C{channel}:BSWV AMP,{vpp:.6f}")
143
+
144
+ def query_channel(self, channel: int) -> dict:
145
+ """
146
+ Query current channel settings.
147
+
148
+ Returns a dict with keys: wvtp, freq_hz, amp_vpp, amp_dbm, ofst, phase_deg.
149
+ """
150
+ self._check_channel(channel)
151
+ resp = self._query(f"C{channel}:BSWV?")
152
+ # Response: "C1:BSWV WVTP,SINE,FRQ,14001000.0,AMP,0.063,..."
153
+ params = {}
154
+ if "BSWV" in resp:
155
+ resp = resp.split("BSWV", 1)[1].strip()
156
+ parts = resp.split(",")
157
+ for i in range(0, len(parts) - 1, 2):
158
+ key = parts[i].strip().upper()
159
+ val = parts[i + 1].strip()
160
+ params[key] = val
161
+
162
+ result = {"wvtp": params.get("WVTP", "?")}
163
+ try:
164
+ result["freq_hz"] = float(params.get("FRQ", 0))
165
+ result["amp_vpp"] = float(params.get("AMP", 0))
166
+ result["amp_dbm"] = vpp_to_dbm(result["amp_vpp"])
167
+ result["ofst"] = float(params.get("OFST", 0))
168
+ result["phase_deg"] = float(params.get("PHSE", 0))
169
+ except (ValueError, ZeroDivisionError):
170
+ pass
171
+ return result
172
+
173
+ def close(self) -> None:
174
+ """Disable outputs and close the TCP connection."""
175
+ try:
176
+ self.output_off_all()
177
+ except Exception:
178
+ pass
179
+ self._sock.close()
180
+
181
+ # ------------------------------------------------------------------
182
+ # Context manager
183
+ # ------------------------------------------------------------------
184
+
185
+ def __enter__(self):
186
+ return self
187
+
188
+ def __exit__(self, *_):
189
+ self.close()
190
+
191
+ # ------------------------------------------------------------------
192
+ # Internal helpers
193
+ # ------------------------------------------------------------------
194
+
195
+ @staticmethod
196
+ def _check_channel(channel: int) -> None:
197
+ if channel not in (1, 2):
198
+ raise ValueError(f"Channel must be 1 or 2, got {channel}")
199
+
200
+ def _check_level(self, level_dbm: float) -> None:
201
+ if not (DBM_MIN <= level_dbm <= DBM_MAX):
202
+ raise ValueError(
203
+ f"Level {level_dbm:.1f} dBm out of range "
204
+ f"[{DBM_MIN:.1f}, {DBM_MAX:.1f}] dBm"
205
+ )
206
+
207
+ def _cmd(self, cmd: str) -> None:
208
+ """Send a command and wait briefly for the instrument to process it."""
209
+ self._sock.sendall((cmd + "\n").encode())
210
+ time.sleep(0.05)
211
+
212
+ def _query(self, cmd: str) -> str:
213
+ """Send a query and return the response string."""
214
+ self._sock.sendall((cmd + "\n").encode())
215
+ time.sleep(0.05)
216
+ try:
217
+ data = self._sock.recv(RECV_BUFSIZE)
218
+ return data.decode(errors="replace").strip()
219
+ except socket.timeout:
220
+ return ""