pypsgctrl 0.1.1__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.
pypsgctrl/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ ## @file
2
+ # @brief Public exports for the PSG9080 driver.
3
+ """PSG9080 serial driver. Import public classes directly from pypsgctrl."""
4
+ from .channel import Channel
5
+ from .device import PSG9080
6
+ from .enums import FrequencyUnit, Modulation, TriggerSource, Waveform
7
+ from .errors import PSGError, PSGTimeoutError, ProtocolError
8
+ from .registers import CHANNEL_REGISTERS, REGISTERS, Register
9
+ from .transport import Transport
10
+
11
+ __version__ = '0.1.1'
12
+ __all__ = [
13
+ 'PSG9080', 'Channel', 'Waveform', 'FrequencyUnit', 'Modulation',
14
+ 'TriggerSource', 'PSGError', 'ProtocolError', 'PSGTimeoutError',
15
+ 'Transport', 'Register', 'REGISTERS', 'CHANNEL_REGISTERS', '__version__',
16
+ ]
pypsgctrl/channel.py ADDED
@@ -0,0 +1,184 @@
1
+ ## @file
2
+ # @brief Channel settings and frequency/output properties.
3
+ from __future__ import annotations
4
+
5
+ from .enums import FrequencyUnit
6
+ from .errors import ProtocolError
7
+ from .registers import CHANNEL_REGISTERS, Register, _reg, _encode, _decode
8
+
9
+ def _parameter(name):
10
+ return property(lambda self: self.get(name), lambda self, value: self.set(name, value),
11
+ doc=f'{name}: physical value; see CHANNEL_REGISTERS.')
12
+
13
+
14
+ ## @brief Parameters of one channel; use PSG9080.ch1, ch2, or channel().
15
+ class Channel:
16
+ ## @brief Create a channel object; normally constructed by PSG9080.
17
+ # @param device Parent PSG9080 instance.
18
+ # @param number Channel number 1 or 2; this constructor does not validate it.
19
+ def __init__(self, device, number):
20
+ self.device, self.number = device, number
21
+
22
+ def _register(self, name):
23
+ reg = CHANNEL_REGISTERS[name]
24
+ return Register(reg.code + self.number - 1, reg.scale, reg.offset,
25
+ reg.minimum, reg.maximum)
26
+
27
+ ## @brief Read a CHANNEL_REGISTERS parameter.
28
+ # @param name Channel parameter name; frequency/enabled are separate properties.
29
+ # @return Decimal in physical units.
30
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
31
+ # @exception ProtocolError Unexpected device response.
32
+ # @exception ValueError Invalid input value.
33
+ # @exception KeyError Unknown parameter name.
34
+ def get(self, name):
35
+ reg = self._register(name)
36
+ return _decode(reg, self.device.read_raw(reg.code))
37
+
38
+ ## @brief Write a channel parameter after validating its value.
39
+ # @param name CHANNEL_REGISTERS parameter name.
40
+ # @param value Number, Decimal, IntEnum, or numeric string in physical units.
41
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
42
+ # @exception ProtocolError Unexpected device response.
43
+ # @exception ValueError Invalid input value.
44
+ # @exception KeyError Unknown parameter name.
45
+ def set(self, name, value):
46
+ reg = self._register(name)
47
+ raw = _encode(reg, value)
48
+ if name == 'waveform' and not (0 <= int(raw) <= 21 or 101 <= int(raw) <= 199):
49
+ raise ValueError('Waveform must be 0..21 or 101..199')
50
+ self.device.write_raw(reg.code, raw)
51
+
52
+ ## @brief Select an arbitrary waveform by slot number.
53
+ # @param slot Integer in 1..99; sends waveform code 100 + slot.
54
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
55
+ # @exception ProtocolError Unexpected device response.
56
+ # @exception ValueError Invalid input value.
57
+ def set_arbitrary_waveform(self, slot):
58
+ raw = _encode(_reg(0, minimum=1, maximum=99), slot)
59
+ self.set('waveform', 100 + int(raw))
60
+
61
+ ## @brief Set frequency in Hz using the chosen display units.
62
+ # @param hz Nonnegative frequency in Hz; MAXF depends on the device.
63
+ # @param display_unit FrequencyUnit; mHz/uHz scaling follows the document and is not hardware-verified.
64
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
65
+ # @exception ProtocolError Unexpected device response.
66
+ # @exception ValueError Invalid input value.
67
+ def set_frequency(self, hz, *, display_unit=FrequencyUnit.HZ):
68
+ unit = FrequencyUnit(display_unit)
69
+ # 0..2 select display units; documented examples retain millihertz
70
+ # ticks. For mHz/uHz, the document shows ticks per selected unit.
71
+ scale = {0: 1000, 1: 1000, 2: 1000, 3: 1000000, 4: 1000000000}[unit]
72
+ self.device.write_raw(12 + self.number,
73
+ _encode(_reg(0, scale=scale), hz), int(unit))
74
+
75
+ ## @brief Read/write frequency in Hz; property assignment selects FrequencyUnit.HZ.
76
+ # @return Decimal when reading.
77
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
78
+ # @exception ProtocolError Unexpected device response.
79
+ # @exception ValueError Invalid input value.
80
+ @property
81
+ def frequency(self):
82
+ fields = self.device.read_raw(12 + self.number)
83
+ if len(fields) != 2:
84
+ raise ProtocolError('Frequency response requires value and unit')
85
+ try:
86
+ unit = FrequencyUnit(int(fields[1]))
87
+ except ValueError as exc:
88
+ raise ProtocolError('Unknown frequency unit') from exc
89
+ scale = {0: 1000, 1: 1000, 2: 1000, 3: 1000000, 4: 1000000000}[unit]
90
+ return _decode(_reg(0, scale=scale), fields[:1])
91
+
92
+ ## @brief Read/write frequency in Hz; property assignment selects FrequencyUnit.HZ.
93
+ # @param hz When writing: nonnegative frequency in Hz.
94
+ # @return Decimal when reading.
95
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
96
+ # @exception ProtocolError Unexpected device response.
97
+ # @exception ValueError Invalid input value.
98
+ @frequency.setter
99
+ def frequency(self, hz):
100
+ self.set_frequency(hz)
101
+
102
+ ## @brief Read/write output state; writing preserves the other channel state.
103
+ # @return bool when reading.
104
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
105
+ # @exception ProtocolError Unexpected device response.
106
+ # @exception ValueError Invalid input value.
107
+ @property
108
+ def enabled(self):
109
+ return bool(self.device.get('outputs')[self.number - 1])
110
+
111
+ ## @brief Read/write output state; writing preserves the other channel state.
112
+ # @param value When writing: bool or 0/1.
113
+ # @return bool when reading.
114
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
115
+ # @exception ProtocolError Unexpected device response.
116
+ # @exception ValueError Invalid input value.
117
+ @enabled.setter
118
+ def enabled(self, value):
119
+ encoded = _encode(_reg(10, maximum=1), value)
120
+ with self.device._lock:
121
+ states = list(self.device.get('outputs'))
122
+ states[self.number - 1] = encoded
123
+ self.device.set('outputs', *states)
124
+
125
+
126
+ ## @brief Waveform code: 0..21 or 101..199; arbitrary waveform 01 has code 101.
127
+ # @return Decimal when reading.
128
+ # @exception ValueError Invalid value when writing.
129
+ waveform = _parameter("waveform")
130
+
131
+ ## @brief Amplitude in Vpp, 0.001 V step; maximum depends on the device.
132
+ # @return Decimal when reading.
133
+ # @exception ValueError Invalid value when writing.
134
+ amplitude = _parameter("amplitude")
135
+
136
+ ## @brief Offset in V, 0.01 V step; minimum -10 V.
137
+ # @return Decimal when reading.
138
+ # @exception ValueError Invalid value when writing.
139
+ offset = _parameter("offset")
140
+
141
+ ## @brief Duty cycle in percent, 0..100, 0.01% step.
142
+ # @return Decimal when reading.
143
+ # @exception ValueError Invalid value when writing.
144
+ duty = _parameter("duty")
145
+
146
+ ## @brief Phase in degrees, 0..359.99, 0.01 degree step.
147
+ # @return Decimal when reading.
148
+ # @exception ValueError Invalid value when writing.
149
+ phase = _parameter("phase")
150
+
151
+ ## @brief Internal modulation frequency in Hz, 0..1000000, 0.001 Hz step.
152
+ # @return Decimal when reading.
153
+ # @exception ValueError Invalid value when writing.
154
+ modulation_frequency = _parameter("modulation_frequency")
155
+
156
+ ## @brief AM depth in percent, 0..200, 0.1% step.
157
+ # @return Decimal when reading.
158
+ # @exception ValueError Invalid value when writing.
159
+ am_depth = _parameter("am_depth")
160
+
161
+ ## @brief FM deviation in Hz, 0.1 Hz step.
162
+ # @return Decimal when reading.
163
+ # @exception ValueError Invalid value when writing.
164
+ fm_deviation = _parameter("fm_deviation")
165
+
166
+ ## @brief FSK frequency in Hz, 0.1 Hz step.
167
+ # @return Decimal when reading.
168
+ # @exception ValueError Invalid value when writing.
169
+ fsk_frequency = _parameter("fsk_frequency")
170
+
171
+ ## @brief PM deviation in degrees, 0..359.9, 0.1 degree step.
172
+ # @return Decimal when reading.
173
+ # @exception ValueError Invalid value when writing.
174
+ pm_deviation = _parameter("pm_deviation")
175
+
176
+ ## @brief Pulse width in seconds, 0..0.4, 1 ns step.
177
+ # @return Decimal when reading.
178
+ # @exception ValueError Invalid value when writing.
179
+ pulse_width = _parameter("pulse_width")
180
+
181
+ ## @brief Pulse period in seconds, 0..4, 10 ns step.
182
+ # @return Decimal when reading.
183
+ # @exception ValueError Invalid value when writing.
184
+ pulse_period = _parameter("pulse_period")
pypsgctrl/device.py ADDED
@@ -0,0 +1,252 @@
1
+ ## @file
2
+ # @brief Serial connection and general device settings.
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import threading
7
+ import time
8
+
9
+ from .channel import Channel
10
+ from .errors import PSGError, PSGTimeoutError, ProtocolError
11
+ from .registers import REGISTERS, _reg, _decimal, _encode, _decode
12
+ from .transport import Transport
13
+
14
+ ## @brief Synchronous PSG9080 connection; transactions are protected by RLock.
15
+ class PSG9080:
16
+ """Synchronous, transaction-locked PSG9080 connection.
17
+
18
+ Injected transports are borrowed unless owns_transport=True. Serial ports
19
+ opened by connect() are owned. A timed-out connection should be reopened:
20
+ the wire protocol has no transaction identifiers.
21
+ """
22
+ ## @brief Create a driver using an injected transport.
23
+ # @param transport Transport object; read() must have a finite timeout.
24
+ # @param owns_transport Close the transport on close(); defaults to False.
25
+ # @param response_timeout Positive finite response deadline in seconds.
26
+ def __init__(self, transport: Transport, *, owns_transport=False, response_timeout=1.0):
27
+ if _decimal(response_timeout) <= 0:
28
+ raise ValueError("response_timeout must be positive")
29
+ self.response_timeout = float(response_timeout)
30
+ self.transport = transport
31
+ self._owns_transport = owns_transport
32
+ self._closed = False
33
+ self._lock = threading.RLock()
34
+ self.ch1 = Channel(self, 1)
35
+ self.ch2 = Channel(self, 2)
36
+
37
+ ## @brief Open a serial port at 115200 baud, 8-N-1, without flow control.
38
+ # @param port Serial port path or pyserial URL.
39
+ # @param timeout Positive finite read, write, and response timeout in seconds.
40
+ # @return PSG9080 owning the opened transport.
41
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
42
+ # @exception ProtocolError Unexpected device response.
43
+ # @exception ValueError Invalid input value.
44
+ @classmethod
45
+ def connect(cls, port: str, *, timeout: float = 1.0):
46
+ if timeout <= 0 or not _decimal(timeout).is_finite():
47
+ raise ValueError('timeout must be positive and finite')
48
+ import serial
49
+ return cls(serial.serial_for_url(port, baudrate=115200, bytesize=8,
50
+ parity='N', stopbits=1, timeout=timeout, write_timeout=timeout),
51
+ owns_transport=True, response_timeout=timeout)
52
+
53
+ ## @brief Close the driver and its owned transport; repeated calls are allowed.
54
+ def close(self):
55
+ with self._lock:
56
+ if not self._closed and self._owns_transport:
57
+ self.transport.close()
58
+ self._closed = True
59
+
60
+ ## @brief Enter the connection context.
61
+ # @return This PSG9080 instance.
62
+ def __enter__(self):
63
+ return self
64
+
65
+ ## @brief Close the connection when leaving the context.
66
+ # @param args Context manager exception information.
67
+ def __exit__(self, *args):
68
+ self.close()
69
+
70
+ ## @brief Get a channel object.
71
+ # @param number Channel number 1 or 2; bool is rejected.
72
+ # @return Channel.
73
+ def channel(self, number):
74
+ if isinstance(number, bool) or number not in (1, 2):
75
+ raise ValueError('Channel must be 1 or 2')
76
+ return self.ch1 if number == 1 else self.ch2
77
+
78
+ def _exchange(self, operator, code, payload):
79
+ if type(code) is not int or not 0 <= code <= 99:
80
+ raise ValueError('Function code must be an integer in [0, 99]')
81
+ if not re.fullmatch(r'[0-9a-fA-F,+-]+', payload):
82
+ raise ValueError('Invalid raw payload')
83
+ command = f':{operator}{code}={payload}.\r\n'.encode('ascii')
84
+ with self._lock:
85
+ if self._closed:
86
+ raise PSGError('Connection is closed')
87
+ if self.transport.write(command) != len(command):
88
+ raise ProtocolError('Incomplete transport write')
89
+ deadline = time.monotonic() + self.response_timeout
90
+ response = bytearray()
91
+ while not response.endswith(b'\r\n'):
92
+ if time.monotonic() >= deadline:
93
+ raise PSGTimeoutError("Response deadline exceeded; reopen connection")
94
+ part = self.transport.read(1)
95
+ if not part:
96
+ raise PSGTimeoutError('Timed out waiting for CRLF response; reopen connection')
97
+ response.extend(part)
98
+ if len(response) > 65536:
99
+ raise ProtocolError('Response exceeds 65536 bytes')
100
+ try:
101
+ line = bytes(response[:-2]).decode('ascii')
102
+ except UnicodeDecodeError as exc:
103
+ raise ProtocolError('Response is not ASCII') from exc
104
+ if operator == 'w':
105
+ if line not in ('OK', 'OK.', ':ok'):
106
+ raise ProtocolError(f'Write rejected: {line!r}')
107
+ return ()
108
+ match = re.fullmatch(r':r(\d{1,2})=([^\r\n]+)\.', line)
109
+ if not match or int(match[1]) != code:
110
+ raise ProtocolError(f'Unexpected read response: {line!r}')
111
+ return tuple(match[2].split(','))
112
+
113
+ ## @brief Read raw fields using :rCODE=0. followed by CRLF.
114
+ # @param code Integer function code in 0..99.
115
+ # @return tuple[str, ...] without unit conversion.
116
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
117
+ # @exception ProtocolError Unexpected device response.
118
+ # @exception ValueError Invalid input value.
119
+ def read_raw(self, code):
120
+ """Return unscaled ASCII fields from :rCODE=0. command."""
121
+ return self._exchange('r', code, '0')
122
+
123
+ ## @brief Write raw ASCII fields and verify an OK, OK., or :ok acknowledgment.
124
+ # @param code Integer function code in 0..99.
125
+ # @param fields Fields without a terminator or CRLF; hexadecimal selectors are supported.
126
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
127
+ # @exception ProtocolError Unexpected device response.
128
+ # @exception ValueError Invalid input value.
129
+ def write_raw(self, code, *fields):
130
+ """Write literal fields, including hexadecimal interface selectors."""
131
+ self._exchange('w', code, ','.join(str(f) for f in fields))
132
+
133
+ ## @brief Read a general parameter from REGISTERS.
134
+ # @param name General register name.
135
+ # @return Decimal or tuple[Decimal, ...]; interface returns tuple[int, ...].
136
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
137
+ # @exception ProtocolError Unexpected device response.
138
+ # @exception ValueError Invalid input value.
139
+ # @exception KeyError Unknown parameter name.
140
+ def get(self, name):
141
+ reg = REGISTERS[name]
142
+ fields = self.read_raw(reg.code)
143
+ if name == 'interface':
144
+ if len(fields) != 4 or any(not re.fullmatch('[0-9a-fA-F]{1,2}', f) for f in fields):
145
+ raise ProtocolError('Invalid interface selector')
146
+ return tuple(int(f, 16) for f in fields)
147
+ if name == 'sync' and len(fields) == 1 and re.fullmatch('[01]{6}', fields[0]):
148
+ fields = tuple(fields[0])
149
+ return _decode(reg, fields)
150
+
151
+ ## @brief Write a general parameter after validating its range and resolution.
152
+ # @param name Writable general register name.
153
+ # @param values Physical values in register field order.
154
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
155
+ # @exception ProtocolError Unexpected device response.
156
+ # @exception ValueError Invalid input value.
157
+ # @exception KeyError Unknown parameter name.
158
+ def set(self, name, *values):
159
+ reg = REGISTERS[name]
160
+ if not reg.writable or name == 'memory':
161
+ raise ValueError('Use memory methods, or register is read-only')
162
+ if len(values) != reg.count:
163
+ raise ValueError(f'{name} requires {reg.count} values')
164
+ if name == 'interface':
165
+ encoded = [_encode(_reg(24, maximum=255), v) for v in values]
166
+ self.write_raw(reg.code, *(format(int(v), 'x') for v in encoded))
167
+ else:
168
+ self.write_raw(reg.code, *(_encode(reg, v) for v in values))
169
+
170
+ ## @brief Set both output states.
171
+ # @param ch1 True/1 enables CH1; False/0 disables it.
172
+ # @param ch2 True/1 enables CH2; False/0 disables it.
173
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
174
+ # @exception ProtocolError Unexpected device response.
175
+ # @exception ValueError Invalid input value.
176
+ def set_outputs(self, ch1: bool, ch2: bool):
177
+ self.set('outputs', ch1, ch2)
178
+
179
+ ## @brief Configure synchronization with CH1 as the leader.
180
+ # @param waveform Synchronization flag for waveform.
181
+ # @param frequency Synchronization flag for frequency.
182
+ # @param amplitude Synchronization flag for amplitude.
183
+ # @param offset Synchronization flag for offset.
184
+ # @param duty Synchronization flag for duty.
185
+ # @param external Synchronization flag for external.
186
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
187
+ # @exception ProtocolError Unexpected device response.
188
+ # @exception ValueError Invalid input value.
189
+ def set_sync(self, *, waveform=False, frequency=False, amplitude=False,
190
+ offset=False, duty=False, external=False):
191
+ self.set('sync', waveform, frequency, amplitude, offset, duty, external)
192
+
193
+ def _memory(self, slot, operation):
194
+ self.write_raw(26, _encode(_reg(26), slot), operation)
195
+
196
+ ## @brief Load parameters from memory using command 26/111.
197
+ # @param slot Nonnegative integer memory slot; the upper limit is undocumented.
198
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
199
+ # @exception ProtocolError Unexpected device response.
200
+ # @exception ValueError Invalid input value.
201
+ def load(self, slot):
202
+ self._memory(slot, 111)
203
+
204
+ ## @brief Save parameters to memory using command 26/222.
205
+ # @param slot Nonnegative integer memory slot.
206
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
207
+ # @exception ProtocolError Unexpected device response.
208
+ # @exception ValueError Invalid input value.
209
+ def save(self, slot):
210
+ self._memory(slot, 222)
211
+
212
+ ## @brief Clear one memory slot using command 26/333.
213
+ # @param slot Nonnegative integer memory slot.
214
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
215
+ # @exception ProtocolError Unexpected device response.
216
+ # @exception ValueError Invalid input value.
217
+ def clear(self, slot):
218
+ self._memory(slot, 333)
219
+
220
+ ## @brief Clear all memory slots using command 26/444.
221
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
222
+ # @exception ProtocolError Unexpected device response.
223
+ # @exception ValueError Invalid input value.
224
+ def clear_all(self):
225
+ self._memory(0, 444)
226
+
227
+ ## @brief Configure measurement using command 62; does not start measurement.
228
+ # @param dc False: AC; True: DC coupling at Ext.IN.
229
+ # @param gate_seconds Gate time in 0.001..10 seconds, with a 0.001 s step.
230
+ # @param low_frequency False: high-frequency mode; True: low-frequency mode.
231
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
232
+ # @exception ProtocolError Unexpected device response.
233
+ # @exception ValueError Invalid input value.
234
+ def configure_measurement(self, *, dc=False, gate_seconds='0.02', low_frequency=False):
235
+ self.write_raw(62, _encode(_reg(62, maximum=1), dc),
236
+ _encode(_reg(62, scale=1000, minimum='0.001', maximum=10), gate_seconds),
237
+ _encode(_reg(62, maximum=1), low_frequency))
238
+
239
+ ## @brief Configure sweep using command 64; does not enable sweep.
240
+ # @param channel Channel 1 or 2.
241
+ # @param seconds Time in 0.01..640 seconds, with a 0.01 s step.
242
+ # @param direction 0: increasing; 1: decreasing; 2: back and forth.
243
+ # @param logarithmic False: linear sweep; True: logarithmic sweep.
244
+ # @exception PSGTimeoutError Incomplete response or expired timeout.
245
+ # @exception ProtocolError Unexpected device response.
246
+ # @exception ValueError Invalid input value.
247
+ def configure_sweep(self, *, channel=1, seconds=10, direction=0, logarithmic=False):
248
+ self.channel(channel)
249
+ self.write_raw(64, channel - 1,
250
+ _encode(_reg(64, scale=100, minimum='0.01', maximum=640), seconds),
251
+ _encode(_reg(64, maximum=2), direction),
252
+ _encode(_reg(64, maximum=1), logarithmic))
pypsgctrl/enums.py ADDED
@@ -0,0 +1,60 @@
1
+ ## @file
2
+ # @brief Protocol enumerations.
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+
7
+ ## @brief Built-in waveform codes; arbitrary waveforms use slots 1..99.
8
+ class Waveform(IntEnum):
9
+ SINE = 0
10
+ SQUARE = 1
11
+ PULSE = 2
12
+ TRIANGLE = 3
13
+ SLOPE = 4
14
+ CMOS = 5
15
+ DC = 6
16
+ PARTIAL_SINE = 7
17
+ HALF_WAVE = 8
18
+ FULL_WAVE = 9
19
+ POSITIVE_LADDER = 10
20
+ NEGATIVE_LADDER = 11
21
+ POSITIVE_TRAPEZOID = 12
22
+ NEGATIVE_TRAPEZOID = 13
23
+ NOISE = 14
24
+ EXPONENTIAL_RISE = 15
25
+ EXPONENTIAL_FALL = 16
26
+ LOGARITHMIC_RISE = 17
27
+ LOGARITHMIC_FALL = 18
28
+ SINKER_PULSE = 19
29
+ MULTI_AUDIO = 20
30
+ LORENZ = 21
31
+
32
+
33
+ ## @brief Frequency display unit codes. API frequencies are always in Hz.
34
+ class FrequencyUnit(IntEnum):
35
+ HZ = 0
36
+ KHZ = 1
37
+ MHZ = 2
38
+ MILLIHZ = 3
39
+ MHZ_SMALL = 3 # compatibility alias for millihertz
40
+ UHZ = 4
41
+
42
+
43
+ ## @brief Modulation type codes for the two channels.
44
+ class Modulation(IntEnum):
45
+ AM = 0
46
+ FM = 1
47
+ PM = 2
48
+ ASK = 3
49
+ FSK = 4
50
+ PSK = 5
51
+ PULSE = 6
52
+ BURST = 7
53
+
54
+
55
+ ## @brief Trigger sources: key, internal, external AC, or external DC.
56
+ class TriggerSource(IntEnum):
57
+ KEY = 0
58
+ INTERNAL = 1
59
+ EXTERNAL_AC = 2
60
+ EXTERNAL_DC = 3
pypsgctrl/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ ## @file
2
+ # @brief Driver exceptions.
3
+ from __future__ import annotations
4
+
5
+ ## @brief Base driver exception.
6
+ class PSGError(Exception):
7
+ """Base driver error."""
8
+
9
+
10
+ ## @brief Malformed response, rejected write, or incomplete write.
11
+ class ProtocolError(PSGError):
12
+ """Malformed, unexpected, or rejected device response."""
13
+
14
+
15
+ ## @brief Response timeout expired; reopen the connection.
16
+ class PSGTimeoutError(PSGError, TimeoutError):
17
+ """No complete response arrived within the transport timeout."""
pypsgctrl/registers.py ADDED
@@ -0,0 +1,121 @@
1
+ ## @file
2
+ # @brief Register metadata and physical-value conversion.
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from decimal import Decimal, InvalidOperation
7
+ from enum import IntEnum
8
+ import re
9
+
10
+ from .errors import ProtocolError
11
+
12
+ ## @brief Immutable register descriptor: raw = value * scale + offset.
13
+ @dataclass(frozen=True)
14
+ class Register:
15
+ ## @brief Protocol function code.
16
+ code: int
17
+ ## @brief Wire ticks per physical unit.
18
+ scale: Decimal = Decimal(1)
19
+ ## @brief Additive wire-value offset.
20
+ offset: Decimal = Decimal(0)
21
+ ## @brief Minimum physical value, or None for no lower bound.
22
+ minimum: Decimal | None = Decimal(0)
23
+ ## @brief Maximum physical value, or None for no upper bound.
24
+ maximum: Decimal | None = None
25
+ ## @brief Number of fields in a response or command.
26
+ count: int = 1
27
+ ## @brief Whether the general API permits writing this register.
28
+ writable: bool = True
29
+
30
+
31
+ def _reg(code, scale=1, offset=0, minimum=0, maximum=None, count=1, writable=True):
32
+ return Register(code, Decimal(str(scale)), Decimal(str(offset)),
33
+ None if minimum is None else Decimal(str(minimum)),
34
+ None if maximum is None else Decimal(str(maximum)), count, writable)
35
+
36
+
37
+ # scale is wire ticks per physical unit. Times are seconds, voltages Vpp/V,
38
+ # angles degrees, duty/depth percent, and frequencies Hz.
39
+ ## @brief General register catalog; field order is documented in api_reference_en.md.
40
+ REGISTERS = {
41
+ 'outputs': _reg(10, maximum=1, count=2),
42
+ 'interface': _reg(24, count=4),
43
+ 'sync': _reg(25, maximum=1, count=6),
44
+ 'memory': _reg(26),
45
+ 'sound': _reg(27, maximum=1),
46
+ 'brightness': _reg(28, maximum=100),
47
+ 'language': _reg(29, maximum=1),
48
+ 'preset_wave_count': _reg(30, maximum=39),
49
+ 'arbitrary_wave_count': _reg(31, maximum=99),
50
+ 'wave_loading': _reg(32, maximum=1),
51
+ 'frequency_trim': _reg(33, minimum=None),
52
+ 'modulation': _reg(40, maximum=7, count=2),
53
+ 'modulation_waveform': _reg(41, maximum=9, count=2),
54
+ 'modulation_source': _reg(42, maximum=1, count=2),
55
+ 'pulse_inversion': _reg(57, maximum=1, count=2),
56
+ 'burst_idle': _reg(58, maximum=2, count=2),
57
+ 'polarity': _reg(59, maximum=1, count=2),
58
+ 'trigger_source': _reg(60, maximum=3, count=2),
59
+ 'burst_count': _reg(61, maximum=1000000000, count=2),
60
+ 'measurement_mode': _reg(63, maximum=1),
61
+ 'sweep_enabled': _reg(65, maximum=1, count=2),
62
+ 'sweep_start_frequency': _reg(66, scale=10),
63
+ 'sweep_end_frequency': _reg(67, scale=10),
64
+ 'sweep_start_amplitude': _reg(68, scale=1000),
65
+ 'sweep_end_amplitude': _reg(69, scale=1000),
66
+ 'sweep_start_duty': _reg(70, scale=100, maximum=100),
67
+ 'sweep_end_duty': _reg(71, scale=100, maximum=100),
68
+ 'voltage_calibration_min': _reg(72),
69
+ 'voltage_calibration_max': _reg(73),
70
+ 'trigger': _reg(74, maximum=1, count=2),
71
+ 'counter': _reg(80, writable=False),
72
+ 'measured_high_frequency': _reg(81, writable=False),
73
+ 'measured_low_frequency': _reg(82, scale=1000, writable=False),
74
+ 'measured_positive_width': _reg(83, scale=1000000000, writable=False),
75
+ 'measured_negative_width': _reg(84, scale=1000000000, writable=False),
76
+ 'measured_period': _reg(85, scale=100000000, writable=False),
77
+ 'measured_duty': _reg(86, scale=100, maximum=100, writable=False),
78
+ }
79
+ ## @brief Channel register catalog: CH2 code equals CH1 code + 1.
80
+ CHANNEL_REGISTERS = {
81
+ 'waveform': _reg(11, maximum=199),
82
+ 'amplitude': _reg(15, scale=1000),
83
+ 'offset': _reg(17, scale=100, offset=1000, minimum=-10),
84
+ 'duty': _reg(19, scale=100, maximum=100),
85
+ 'phase': _reg(21, scale=100, maximum='359.99'),
86
+ 'modulation_frequency': _reg(43, scale=1000, maximum=1000000),
87
+ 'am_depth': _reg(45, scale=10, maximum=200),
88
+ 'fm_deviation': _reg(47, scale=10),
89
+ 'fsk_frequency': _reg(49, scale=10),
90
+ 'pm_deviation': _reg(51, scale=10, maximum='359.9'),
91
+ 'pulse_width': _reg(53, scale=1000000000, maximum='0.4'),
92
+ 'pulse_period': _reg(55, scale=100000000, maximum=4),
93
+ }
94
+
95
+
96
+ def _decimal(value):
97
+ try:
98
+ d = Decimal(int(value)) if isinstance(value, (bool, IntEnum)) else Decimal(str(value))
99
+ except (InvalidOperation, ValueError, TypeError) as exc:
100
+ raise ValueError('Expected a finite numeric value') from exc
101
+ if not d.is_finite():
102
+ raise ValueError('Expected a finite numeric value')
103
+ return d
104
+
105
+
106
+ def _encode(reg, value):
107
+ d = _decimal(value)
108
+ if ((reg.minimum is not None and d < reg.minimum) or
109
+ (reg.maximum is not None and d > reg.maximum)):
110
+ raise ValueError(f'Value outside range [{reg.minimum}, {reg.maximum}]')
111
+ raw = d * reg.scale + reg.offset
112
+ if raw != raw.to_integral_value():
113
+ raise ValueError(f'Value must be a multiple of {1 / reg.scale}')
114
+ return str(int(raw))
115
+
116
+
117
+ def _decode(reg, fields):
118
+ if len(fields) != reg.count or any(not re.fullmatch(r'-?\d+', f) for f in fields):
119
+ raise ProtocolError(f'Invalid payload for register {reg.code}: {fields!r}')
120
+ values = tuple((Decimal(f) - reg.offset) / reg.scale for f in fields)
121
+ return values[0] if reg.count == 1 else values
pypsgctrl/transport.py ADDED
@@ -0,0 +1,18 @@
1
+ ## @file
2
+ # @brief Injected transport interface.
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ ## @brief Transport contract requiring a finite read timeout.
8
+ class Transport(Protocol):
9
+ ## @brief Write command bytes.
10
+ # @param data ASCII command terminated with CRLF.
11
+ # @return Number of bytes actually written.
12
+ def write(self, data: bytes) -> int: ...
13
+ ## @brief Read up to size bytes with a finite timeout.
14
+ # @param size Requested byte count.
15
+ # @return bytes; empty bytes indicate a timeout.
16
+ def read(self, size: int = 1) -> bytes: ...
17
+ ## @brief Close transport resources.
18
+ def close(self) -> None: ...
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: pypsgctrl
3
+ Version: 0.1.1
4
+ Summary: Python driver for the PSG9080 dual-channel function generator with USB serial control.
5
+ Author: Oleg Kochetov
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/realspinner/pypsgctrl
8
+ Project-URL: Documentation, https://github.com/realspinner/pypsgctrl/blob/main/docs/api_reference_en.md
9
+ Project-URL: Issues, https://github.com/realspinner/pypsgctrl/issues
10
+ Keywords: PSG9080,function-generator,serial,instrument-control
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: pyserial>=3.5
19
+ Dynamic: license-file
20
+
21
+ # pypsgctrl
22
+
23
+ English documentation is followed by the Russian translation.
24
+ После английского текста приведена русская версия.
25
+
26
+ ## English
27
+
28
+ A synchronous Python driver for the PSG9080 dual-channel function generator over
29
+ USB serial. Supports waveforms, frequency, amplitude, offset, duty cycle, phase,
30
+ modulation, measurement, sweep, system settings, and parameter memory.
31
+
32
+ ### Installation
33
+
34
+ Python 3.10 or newer is required. pyserial is installed automatically.
35
+
36
+ For releases published on PyPI:
37
+
38
+ ```sh
39
+ python -m pip install pypsgctrl
40
+ ```
41
+
42
+ The package is available on TestPyPI; production publication is pending.
43
+ Until then, install directly from GitHub:
44
+
45
+ ```sh
46
+ python -m pip install "git+https://github.com/realspinner/pypsgctrl.git"
47
+ ```
48
+
49
+ To install this release from TestPyPI in a fresh virtual environment:
50
+
51
+ ```sh
52
+ python -m pip install pyserial
53
+ python -m pip install --index-url https://test.pypi.org/simple/ --no-deps pypsgctrl==0.1.1
54
+ ```
55
+
56
+ After cloning the repository, use `python -m pip install -e .` for development. Serial settings: 115200 baud,
57
+ 8-N-1, without flow control. Replace the macOS example port with your device's port.
58
+ Opening a connection does not enable outputs.
59
+
60
+ ### Quick start
61
+
62
+ ```python
63
+ from pypsgctrl import PSG9080, Waveform
64
+
65
+ with PSG9080.connect('/dev/cu.usbserial-2120', timeout=1.0) as generator:
66
+ print(generator.ch1.frequency) # Decimal, Hz
67
+ generator.ch1.waveform = Waveform.SINE
68
+ generator.ch1.frequency = 1000
69
+ generator.ch1.amplitude = '0.100' # Vpp, not RMS
70
+ generator.ch1.offset = 0 # V
71
+ generator.set_outputs(True, False)
72
+ ```
73
+
74
+ Writes take effect immediately. Physical readbacks use Decimal; numeric strings
75
+ and Decimal support exact fractions. Values between device steps are rejected.
76
+ Transactions are serialized with an instance lock. After a timeout, close and
77
+ reopen the connection. Hardware limits absent from the protocol remain the caller's responsibility.
78
+
79
+ ### Documentation and structure
80
+
81
+ [English API reference](https://github.com/realspinner/pypsgctrl/blob/main/docs/api_reference_en.md) · [Russian API reference](https://github.com/realspinner/pypsgctrl/blob/main/docs/api_reference_ru.md)
82
+
83
+ | Module | Purpose |
84
+ | --- | --- |
85
+ | `pypsgctrl/__init__.py` | Stable public imports and version |
86
+ | `pypsgctrl/device.py` | Connection, wire exchange, general settings |
87
+ | `pypsgctrl/channel.py` | Channel properties, frequency/output control |
88
+ | `pypsgctrl/registers.py` | Register metadata and unit conversion |
89
+ | `pypsgctrl/enums.py` | Waveform, frequency, modulation, trigger enums |
90
+ | `pypsgctrl/errors.py` | Driver exceptions |
91
+ | `pypsgctrl/transport.py` | Injectable transport contract |
92
+
93
+ Public imports use `from pypsgctrl import PSG9080, Waveform`.
94
+ Report bugs through [GitHub Issues](https://github.com/realspinner/pypsgctrl/issues). Source comments use Doxygen
95
+ tags. With Doxygen installed, run `doxygen Doxyfile` from the repository root to generate
96
+ `build/doxygen/html`.
97
+
98
+ ### License
99
+
100
+ MIT License, copyright 2026 Oleg Kochetov. See [LICENSE](https://github.com/realspinner/pypsgctrl/blob/main/LICENSE).
101
+ Vendor PDFs and recorder calibration utilities are not included in this repository.
102
+
103
+ ## Русский
104
+
105
+ Синхронный Python-драйвер двухканального генератора PSG9080 по USB serial.
106
+ Поддерживает форму, частоту, амплитуду, смещение, duty, фазу, модуляцию,
107
+ измерение, sweep, системные настройки и память параметров.
108
+
109
+ ### Установка
110
+
111
+ Требуется Python 3.10 или новее. pyserial устанавливается автоматически.
112
+
113
+ Для релизов, опубликованных на основном PyPI:
114
+
115
+ ```sh
116
+ python -m pip install pypsgctrl
117
+ ```
118
+
119
+ Пакет доступен на TestPyPI; публикация на основном PyPI ещё не выполнена.
120
+ До неё можно установить пакет из GitHub:
121
+
122
+ ```sh
123
+ python -m pip install "git+https://github.com/realspinner/pypsgctrl.git"
124
+ ```
125
+
126
+ Установка этой версии с TestPyPI в новом виртуальном окружении:
127
+
128
+ ```sh
129
+ python -m pip install pyserial
130
+ python -m pip install --index-url https://test.pypi.org/simple/ --no-deps pypsgctrl==0.1.1
131
+ ```
132
+
133
+ После клонирования для разработки: `python -m pip install -e .`. Порт: 115200 baud, 8-N-1,
134
+ без flow control. Замените пример macOS на свой порт. Подключение не включает выходы.
135
+
136
+ ### Быстрый старт
137
+
138
+ ```python
139
+ from pypsgctrl import PSG9080, Waveform
140
+
141
+ with PSG9080.connect('/dev/cu.usbserial-2120', timeout=1.0) as generator:
142
+ print(generator.ch1.frequency) # Decimal, Hz
143
+ generator.ch1.waveform = Waveform.SINE
144
+ generator.ch1.frequency = 1000
145
+ generator.ch1.amplitude = '0.100' # Vpp, not RMS
146
+ generator.ch1.offset = 0 # V
147
+ generator.set_outputs(True, False)
148
+ ```
149
+
150
+ Записи применяются немедленно. Физические значения возвращаются как Decimal;
151
+ строки и Decimal позволяют задать точные дроби. Значения между шагами отвергаются.
152
+ Обмен защищён блокировкой экземпляра. После таймаута соединение следует открыть
153
+ заново. Неописанные в протоколе аппаратные пределы учитывает вызывающий код.
154
+
155
+ ### Документация и структура
156
+
157
+ [Справочник на английском](https://github.com/realspinner/pypsgctrl/blob/main/docs/api_reference_en.md) · [Справочник на русском](https://github.com/realspinner/pypsgctrl/blob/main/docs/api_reference_ru.md)
158
+
159
+ | Модуль | Назначение |
160
+ | --- | --- |
161
+ | `pypsgctrl/__init__.py` | Стабильные публичные импорты и версия |
162
+ | `pypsgctrl/device.py` | Соединение, обмен, общие настройки |
163
+ | `pypsgctrl/channel.py` | Свойства канала, частота и управление выходом |
164
+ | `pypsgctrl/registers.py` | Метаданные регистров и преобразование единиц |
165
+ | `pypsgctrl/enums.py` | Перечисления формы, частоты, модуляции и запуска |
166
+ | `pypsgctrl/errors.py` | Исключения драйвера |
167
+ | `pypsgctrl/transport.py` | Контракт внедряемого транспорта |
168
+
169
+ Публичные импорты: `from pypsgctrl import PSG9080, Waveform`.
170
+ Об ошибках можно сообщить через [GitHub Issues](https://github.com/realspinner/pypsgctrl/issues). Комментарии оформлены
171
+ тегами Doxygen. При установленном Doxygen команда `doxygen Doxyfile` из корня репозитория
172
+ генерирует `build/doxygen/html`.
173
+
174
+ ### Лицензия
175
+
176
+ MIT License, правообладатель — Oleg Kochetov, 2026. См. [LICENSE](https://github.com/realspinner/pypsgctrl/blob/main/LICENSE).
177
+ PDF производителя и утилиты калибровки рекордера в репозиторий не включены.
@@ -0,0 +1,12 @@
1
+ pypsgctrl/__init__.py,sha256=s3QGiDmfm6eor_QaAwE6_ZZ1fzjaSSlvSelK7Z2nnRQ,669
2
+ pypsgctrl/channel.py,sha256=wKzM8MJ0GjKJdphCtgQzAtDO5CrQs82CYY7MYfZLjGo,8259
3
+ pypsgctrl/device.py,sha256=Dnk9nsoAbji9g0bP4w3sAwKLnGJKfzaE5BdsJCfNSKs,12467
4
+ pypsgctrl/enums.py,sha256=mNwRtALvujtQsS1ip77FZlyP-g6kFoMk-JcILuYtu50,1215
5
+ pypsgctrl/errors.py,sha256=9Xd8Aozv1WfmyiV-2Qv5oCXmO8Fbg6TsXiJaityfL0w,501
6
+ pypsgctrl/registers.py,sha256=_Y1Ajj_xJOOyw3MyGyjng-zdwEwC3mUH6QMwdugCS8c,5150
7
+ pypsgctrl/transport.py,sha256=2jUCM33wOzocRtZo2PcRL1uJyljrYzBFDcv1I4hBhio,658
8
+ pypsgctrl-0.1.1.dist-info/licenses/LICENSE,sha256=ser5brmfUMyLrjLL3PC9egkI4kN-qMEi6eQkBbkfJ7g,1070
9
+ pypsgctrl-0.1.1.dist-info/METADATA,sha256=IVwLdu0Jz25AsP27Db1UpgBPB2H_vhJVh1DQCxVlvBY,8274
10
+ pypsgctrl-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ pypsgctrl-0.1.1.dist-info/top_level.txt,sha256=N-BDXO0PlibGCG7F6tGDBtRPEsRLPrVK7B1xHd_v1To,10
12
+ pypsgctrl-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleg Kochetov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pypsgctrl