pypsgctrl 0.1.1__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ PROJECT_NAME = pypsgctrl
2
+ PROJECT_NUMBER = 0.1.1
3
+ OUTPUT_DIRECTORY = build/doxygen
4
+ INPUT = pypsgctrl readme.md docs/api_reference_en.md docs/api_reference_ru.md
5
+ FILE_PATTERNS = *.py *.md
6
+ RECURSIVE = YES
7
+ OPTIMIZE_OUTPUT_JAVA = YES
8
+ EXTRACT_ALL = YES
9
+ GENERATE_HTML = YES
10
+ GENERATE_LATEX = NO
11
+ QUIET = YES
12
+ MARKDOWN_ID_STYLE = GITHUB
@@ -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,5 @@
1
+ include Doxyfile
2
+ recursive-include docs *.md
3
+ include tests/test_pypsgctrl.py
4
+ exclude tests/test_calibrate_recorder.py
5
+ exclude calibrate_recorder.py check_connection.py check_write.py demonstrate.py
@@ -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,377 @@
1
+ # API reference: pypsgctrl 0.1.1
2
+
3
+ [Русская версия](api_reference_ru.md)
4
+
5
+ PSG9080 driver for Python >= 3.10. Protocol source: `PSG Communication Protocol.pdf` (manufacturer document, not distributed here).
6
+ Public imports live in `pypsgctrl/__init__.py`; implementation is split into
7
+ `device.py`, `channel.py`, `registers.py`, `enums.py`, `errors.py`, and `transport.py`.
8
+ Serial dependency: `pyserial>=3.5`. Source documentation uses Doxygen comments
9
+ `## @brief`, `@param`, `@return`, and `@exception`.
10
+
11
+ ## Quick start
12
+
13
+ From the repository root, install the package and run the tests:
14
+
15
+ ```sh
16
+ python -m pip install .
17
+ python -m unittest discover -s tests -v
18
+ ```
19
+
20
+ ```python
21
+ from pypsgctrl import PSG9080, Waveform
22
+
23
+ with PSG9080.connect('/dev/cu.usbserial-2120', timeout=1.0) as psg:
24
+ print(psg.ch1.frequency) # Decimal, Hz
25
+ print(psg.ch1.amplitude) # Decimal, Vpp
26
+ psg.ch1.waveform = Waveform.SINE
27
+ psg.ch1.frequency = 3000
28
+ psg.ch1.amplitude = '5.000'
29
+ psg.set_outputs(True, False)
30
+ ```
31
+
32
+ Writes change the device immediately. Opening a port does not enable outputs.
33
+ When synchronization is active, CH1 changes may affect CH2 according to the device settings.
34
+
35
+ ## Types, units, and precision
36
+
37
+ Numeric inputs accept int, float, Decimal, IntEnum, and numeric strings.
38
+ Strings and Decimal represent exact fractions. NaN/Infinity are rejected;
39
+ values between register steps are rejected without rounding.
40
+ Physical values and numeric selectors are returned as Decimal; compound registers
41
+ return tuple[Decimal, ...]. Exceptions: enabled returns bool, interface returns
42
+ tuple[int, ...], and read_raw() returns tuple[str, ...]. Reads validate syntax
43
+ and field count but do not enforce the write range on device responses.
44
+
45
+ Frequencies are in Hz, durations in seconds, amplitudes in Vpp, offsets in V,
46
+ angles in degrees, and duty/AM depth in percent. Undocumented hardware limits,
47
+ such as MAXF and maximum amplitude, must be respected by the caller.
48
+
49
+ ## PSG9080 connection
50
+
51
+ | API | Purpose |
52
+ | --- | --- |
53
+ | `PSG9080.connect(port, *, timeout=1.0)` | Opens a path/URL using pyserial at 115200, 8-N-1, without flow control. Returns a PSG9080 owning the port. A positive finite timeout applies to reads, writes, and responses. |
54
+ | `PSG9080(transport, *, owns_transport=False, response_timeout=1.0)` | Injects a transport; borrowed by default. |
55
+ | `close()` | Closes the driver and its owned transport; repeated calls are allowed. |
56
+ | `with PSG9080.connect(...) as psg` | Closes the connection even when an exception occurs. |
57
+ | `ch1`, `ch2`, `channel(number)` | Return Channel; number must be 1 or 2, not bool. |
58
+ | `get(name)` | Reads a general register from the catalog below. |
59
+ | `set(name, *values)` | Validates range/resolution and writes all fields. Returns None. |
60
+ | `set_outputs(ch1, ch2)` | Sets both outputs; True/1 enables and False/0 disables. |
61
+ | `set_sync(*, waveform=False, frequency=False, amplitude=False, offset=False, duty=False, external=False)` | Writes six synchronization flags; CH1 is the leader. |
62
+ | `load(slot)` / `save(slot)` / `clear(slot)` | Command 26, operations 111/222/333. slot is a nonnegative integer; the upper limit is undocumented. |
63
+ | `clear_all()` | Clears all memory slots using command 26/444. |
64
+
65
+ Read memory with get(); use the memory methods for writes. Saving, loading,
66
+ and clearing immediately affect memory or settings.
67
+
68
+ ## Channel
69
+
70
+ Obtain Channel through the connection. The public constructor
71
+ `Channel(device, number)` does not validate the number; `PSG9080.channel()` does.
72
+ `ch.get(name)` and `ch.set(name, value)` use CHANNEL_REGISTERS.
73
+ frequency/enabled are separate properties, not names in that catalog.
74
+ All properties below support reading and writing.
75
+
76
+ | Property | Codes CH1 / CH2 | Unit | Step | Write range |
77
+ | --- | --- | --- | --- | --- |
78
+ | `waveform` | 11 / 12 | code | 1 | 0..21 or 101..199 |
79
+ | `amplitude` | 15 / 16 | Vpp | 0.001 | 0..device limit |
80
+ | `offset` | 17 / 18 | V | 0.01 | -10..device limit |
81
+ | `duty` | 19 / 20 | % | 0.01 | 0..100 |
82
+ | `phase` | 21 / 22 | ° | 0.01 | 0..359.99 |
83
+ | `modulation_frequency` | 43 / 44 | Hz | 0.001 | 0..1000000 |
84
+ | `am_depth` | 45 / 46 | % | 0.1 | 0..200 |
85
+ | `fm_deviation` | 47 / 48 | Hz | 0.1 | 0..device limit |
86
+ | `fsk_frequency` | 49 / 50 | Hz | 0.1 | 0..device limit |
87
+ | `pm_deviation` | 51 / 52 | ° | 0.1 | 0..359.9 |
88
+ | `pulse_width` | 53 / 54 | s | 1E-9 | 0..0.4 |
89
+ | `pulse_period` | 55 / 56 | s | 1E-8 | 0..4 |
90
+ | `frequency` | 13 / 14 | Hz | Usually 0.001 | >= 0, MAXF depends on the device |
91
+ | `enabled` | 10 (pair) | bool | — | False/True |
92
+
93
+ Assigning enabled reads both output states and preserves the other channel.
94
+ The read/modify/write sequence uses the instance lock. waveform returns a
95
+ Decimal code; use `Waveform(int(ch.waveform))` for known built-in waveforms.
96
+ Arbitrary waveform codes cannot be converted to Waveform.
97
+
98
+ | Method | Purpose |
99
+ | --- | --- |
100
+ | `set_arbitrary_waveform(slot)` | Converts slot 1..99 to waveform code 101..199. |
101
+ | `set_frequency(hz, *, display_unit=FrequencyUnit.HZ)` | Always accepts Hz; selects display units separately. |
102
+
103
+ Assigning ch.frequency selects HZ. Protocol examples use the same 0.001 Hz ticks
104
+ for HZ/KHZ/MHZ. MILLIHZ/UHZ are interpreted as 0.000001/0.000000001 Hz steps;
105
+ these display modes have not been verified on hardware.
106
+
107
+ ## General registers
108
+
109
+ Pass compound values as separate arguments: `psg.set('outputs', True, False)`,
110
+ rather than a single tuple. R means read-only; RW means read/write.
111
+ A step of one also applies to selectors and counters.
112
+
113
+ | Name | Code | Fields | Access | Scale: ticks/unit | Write range per field |
114
+ | --- | --- | --- | --- | --- | --- |
115
+ | `outputs` | 10 | 2 | RW | 1 | 0..1 |
116
+ | `interface` | 24 | 4 | RW | 1 | 0..255 (hex selectors) |
117
+ | `sync` | 25 | 6 | RW | 1 | 0..1 |
118
+ | `memory` | 26 | 1 | R | 1 | 0..unspecified |
119
+ | `sound` | 27 | 1 | RW | 1 | 0..1 |
120
+ | `brightness` | 28 | 1 | RW | 1 | 0..100 |
121
+ | `language` | 29 | 1 | RW | 1 | 0..1 |
122
+ | `preset_wave_count` | 30 | 1 | RW | 1 | 0..39 |
123
+ | `arbitrary_wave_count` | 31 | 1 | RW | 1 | 0..99 |
124
+ | `wave_loading` | 32 | 1 | RW | 1 | 0..1 |
125
+ | `frequency_trim` | 33 | 1 | RW | 1 | unspecified..unspecified |
126
+ | `modulation` | 40 | 2 | RW | 1 | 0..7 |
127
+ | `modulation_waveform` | 41 | 2 | RW | 1 | 0..9 |
128
+ | `modulation_source` | 42 | 2 | RW | 1 | 0..1 |
129
+ | `pulse_inversion` | 57 | 2 | RW | 1 | 0..1 |
130
+ | `burst_idle` | 58 | 2 | RW | 1 | 0..2 |
131
+ | `polarity` | 59 | 2 | RW | 1 | 0..1 |
132
+ | `trigger_source` | 60 | 2 | RW | 1 | 0..3 |
133
+ | `burst_count` | 61 | 2 | RW | 1 | 0..1000000000 |
134
+ | `measurement_mode` | 63 | 1 | RW | 1 | 0..1 |
135
+ | `sweep_enabled` | 65 | 2 | RW | 1 | 0..1 |
136
+ | `sweep_start_frequency` | 66 | 1 | RW | 10 | 0..unspecified |
137
+ | `sweep_end_frequency` | 67 | 1 | RW | 10 | 0..unspecified |
138
+ | `sweep_start_amplitude` | 68 | 1 | RW | 1000 | 0..unspecified |
139
+ | `sweep_end_amplitude` | 69 | 1 | RW | 1000 | 0..unspecified |
140
+ | `sweep_start_duty` | 70 | 1 | RW | 100 | 0..100 |
141
+ | `sweep_end_duty` | 71 | 1 | RW | 100 | 0..100 |
142
+ | `voltage_calibration_min` | 72 | 1 | RW | 1 | 0..unspecified |
143
+ | `voltage_calibration_max` | 73 | 1 | RW | 1 | 0..unspecified |
144
+ | `trigger` | 74 | 2 | RW | 1 | 0..1 |
145
+ | `counter` | 80 | 1 | R | 1 | 0..unspecified |
146
+ | `measured_high_frequency` | 81 | 1 | R | 1 | 0..unspecified |
147
+ | `measured_low_frequency` | 82 | 1 | R | 1000 | 0..unspecified |
148
+ | `measured_positive_width` | 83 | 1 | R | 1000000000 | 0..unspecified |
149
+ | `measured_negative_width` | 84 | 1 | R | 1000000000 | 0..unspecified |
150
+ | `measured_period` | 85 | 1 | R | 100000000 | 0..unspecified |
151
+ | `measured_duty` | 86 | 1 | R | 100 | 0..100 |
152
+
153
+ Compound field order and meanings:
154
+
155
+ | Name | Fields / values |
156
+ | --- | --- |
157
+ | outputs | CH1, CH2; 0 off, 1 on |
158
+ | interface | Four hexadecimal UI selectors matching device menus |
159
+ | sync | waveform, frequency, amplitude, offset, duty, external; 0/1 |
160
+ | modulation | CH1, CH2; Modulation enum |
161
+ | modulation_waveform | CH1, CH2; 0 sine, 1 square, 2 triangle, 3 rising sawtooth, 4 falling sawtooth, 5..9 arbitrary 101..105 |
162
+ | modulation_source | CH1, CH2; 0 internal, 1 external |
163
+ | pulse_inversion | CH1, CH2; 0 normal, 1 inverted |
164
+ | burst_idle | CH1, CH2; 0 zero, 1 positive maximum, 2 negative maximum |
165
+ | polarity | CH1, CH2; 0 positive, 1 negative |
166
+ | trigger_source | CH1, CH2; TriggerSource enum |
167
+ | burst_count | Pulse counts for CH1, CH2 |
168
+ | sweep_enabled | Sweep, voltage control; 0/1 |
169
+ | trigger | CH1, CH2; 0/1 |
170
+
171
+ Other selectors: sound 0/1; brightness in percent; language 0 English/1 Chinese;
172
+ wave_loading 0 automatic/1 fast; measurement_mode 0 counter/1 measurement.
173
+ The frequency_trim range and physical unit are undocumented; the API passes
174
+ an integer without scaling. voltage_calibration_min/max are raw integers.
175
+ measured_high_frequency uses a 1 Hz step; measured_low_frequency 0.001 Hz;
176
+ measured_positive_width/negative_width 1 ns; measured_period 10 ns;
177
+ measured_duty 0.01%. sweep_start/end_frequency use 0.1 Hz,
178
+ sweep_start/end_amplitude 0.001 Vpp, and sweep_start/end_duty 0.01%.
179
+
180
+ ## Measurement and sweep
181
+
182
+ `configure_measurement(*, dc=False, gate_seconds='0.02', low_frequency=False)`
183
+ writes command 62: AC/DC coupling, gate time 0.001..10 s with a 0.001 s step,
184
+ and high/low frequency mode. Configuration does not start measurement.
185
+
186
+ `configure_sweep(*, channel=1, seconds=10, direction=0, logarithmic=False)`
187
+ writes command 64: channel 1/2, time 0.01..640 s with a 0.01 s step,
188
+ direction 0 increasing/1 decreasing/2 back and forth, linear or logarithmic mode.
189
+ Enable sweep separately. Read commands 62/64 using read_raw(), without unit conversion.
190
+
191
+ ```python
192
+ from pypsgctrl import PSG9080
193
+
194
+ with PSG9080.connect('/dev/cu.usbserial-2120') as psg:
195
+ psg.configure_measurement(dc=False, gate_seconds='0.1', low_frequency=True)
196
+ psg.set('measurement_mode', 1)
197
+ print(psg.get('measured_low_frequency')) # Hz
198
+ print(psg.get('measured_duty')) # %
199
+
200
+ psg.set('sweep_start_frequency', 100)
201
+ psg.set('sweep_end_frequency', 10000)
202
+ psg.configure_sweep(channel=1, seconds=10, direction=2, logarithmic=True)
203
+ psg.set('sweep_enabled', 1, 0)
204
+ # Disable sweep when finished:
205
+ psg.set('sweep_enabled', 0, 0)
206
+ ```
207
+
208
+ ## Enumerations
209
+
210
+ ### Waveform
211
+
212
+ | Name | Code |
213
+ | --- | --- |
214
+ | `SINE` | 0 |
215
+ | `SQUARE` | 1 |
216
+ | `PULSE` | 2 |
217
+ | `TRIANGLE` | 3 |
218
+ | `SLOPE` | 4 |
219
+ | `CMOS` | 5 |
220
+ | `DC` | 6 |
221
+ | `PARTIAL_SINE` | 7 |
222
+ | `HALF_WAVE` | 8 |
223
+ | `FULL_WAVE` | 9 |
224
+ | `POSITIVE_LADDER` | 10 |
225
+ | `NEGATIVE_LADDER` | 11 |
226
+ | `POSITIVE_TRAPEZOID` | 12 |
227
+ | `NEGATIVE_TRAPEZOID` | 13 |
228
+ | `NOISE` | 14 |
229
+ | `EXPONENTIAL_RISE` | 15 |
230
+ | `EXPONENTIAL_FALL` | 16 |
231
+ | `LOGARITHMIC_RISE` | 17 |
232
+ | `LOGARITHMIC_FALL` | 18 |
233
+ | `SINKER_PULSE` | 19 |
234
+ | `MULTI_AUDIO` | 20 |
235
+ | `LORENZ` | 21 |
236
+
237
+ ### FrequencyUnit
238
+
239
+ | Name | Code |
240
+ | --- | --- |
241
+ | `HZ` | 0 |
242
+ | `KHZ` | 1 |
243
+ | `MHZ` | 2 |
244
+ | `MILLIHZ` | 3 |
245
+ | `MHZ_SMALL` | 3 |
246
+ | `UHZ` | 4 |
247
+
248
+ ### Modulation
249
+
250
+ | Name | Code |
251
+ | --- | --- |
252
+ | `AM` | 0 |
253
+ | `FM` | 1 |
254
+ | `PM` | 2 |
255
+ | `ASK` | 3 |
256
+ | `FSK` | 4 |
257
+ | `PSK` | 5 |
258
+ | `PULSE` | 6 |
259
+ | `BURST` | 7 |
260
+
261
+ ### TriggerSource
262
+
263
+ | Name | Code |
264
+ | --- | --- |
265
+ | `KEY` | 0 |
266
+ | `INTERNAL` | 1 |
267
+ | `EXTERNAL_AC` | 2 |
268
+ | `EXTERNAL_DC` | 3 |
269
+
270
+
271
+ MHZ_SMALL is a compatibility alias for MILLIHZ (millihertz), not megahertz.
272
+
273
+ ## Modulation, memory, and arbitrary waveform examples
274
+
275
+ ```python
276
+ from pypsgctrl import PSG9080, Modulation, TriggerSource
277
+
278
+ with PSG9080.connect('/dev/cu.usbserial-2120') as psg:
279
+ psg.set('modulation', Modulation.AM, Modulation.BURST)
280
+ psg.set('modulation_source', 0, 0)
281
+ psg.ch1.am_depth = 80
282
+ psg.ch1.modulation_frequency = 500
283
+ psg.set('trigger_source', TriggerSource.KEY, TriggerSource.EXTERNAL_DC)
284
+ psg.set('burst_count', 10, 20)
285
+ psg.ch2.set_arbitrary_waveform(1) # Select existing arbitrary waveform 01
286
+ # Uploading samples is undocumented by this protocol and is not implemented.
287
+ psg.save(52)
288
+ psg.load(52)
289
+ ```
290
+
291
+ ## Raw API and transport
292
+
293
+ `read_raw(code)` sends `:rCODE=0.\r\n` and returns a tuple of strings.
294
+ `write_raw(code, *fields)` sends `:wCODE=FIELDS.\r\n` and returns None.
295
+ The code must be int in 0..99. Fields are checked against the ASCII alphabet;
296
+ raw-command semantics, field counts, and ranges are not validated.
297
+ Pass integer wire values without a trailing period or CRLF. Physical fractions
298
+ must be scaled beforehand. The read-response code must match the request.
299
+ Write acknowledgments are OK, OK., or the hardware-observed :ok, followed by CRLF.
300
+
301
+ ```python
302
+ from pypsgctrl import PSG9080
303
+
304
+ with PSG9080.connect('/dev/cu.usbserial-2120') as psg:
305
+ print(psg.read_raw(13)) # Example: ('000003000000', '0')
306
+ psg.write_raw(24, '0', '1', '0', 'a')
307
+ # Equivalent general API call:
308
+ psg.set('interface', 0, 1, 0, 10)
309
+ ```
310
+
311
+ Transport is a typing.Protocol with `write(data: bytes) -> int`,
312
+ `read(size: int = 1) -> bytes`, and `close() -> None`.
313
+ read() must have a finite timeout; the driver cannot interrupt a blocking call.
314
+ response_timeout is checked between read() calls, so a slow transport may exceed
315
+ it by the duration of one read. Partial writes are errors; responses are limited
316
+ to 65536 bytes.
317
+
318
+ ```python
319
+ import serial
320
+ from pypsgctrl import PSG9080
321
+
322
+ transport = serial.Serial('/dev/cu.usbserial-2120', 115200,
323
+ timeout=1, write_timeout=1)
324
+ try:
325
+ with PSG9080(transport, owns_transport=False, response_timeout=1) as psg:
326
+ print(psg.ch1.frequency)
327
+ # A borrowed transport remains open.
328
+ finally:
329
+ transport.close()
330
+ ```
331
+
332
+ ## Register and catalogs
333
+
334
+ `Register(code, scale=Decimal(1), offset=Decimal(0), minimum=Decimal(0),
335
+ maximum=None, count=1, writable=True)` is a frozen dataclass.
336
+ Encoding uses `raw = physical * scale + offset`; decoding performs the inverse.
337
+ minimum/maximum apply to physical values; None means no bound.
338
+ Channel offset has scale=100 and offset=1000: 0 V maps to raw 1000.
339
+ REGISTERS and CHANNEL_REGISTERS are accessible descriptor dictionaries.
340
+ The CH2 code equals the CH1 code + 1. interface, sync, and frequency have special formats.
341
+
342
+ ## Errors and concurrency
343
+
344
+ | Exception | Cause |
345
+ | --- | --- |
346
+ | PSGError | Base exception; also access to a closed driver |
347
+ | ProtocolError(PSGError) | Partial write, invalid ASCII/framing/code/field count, or rejected write |
348
+ | PSGTimeoutError(PSGError, TimeoutError) | Incomplete response or expired response deadline |
349
+ | ValueError | Invalid value, resolution, code, channel, field count, or write to a read-only register |
350
+ | KeyError | Unknown get/set name |
351
+ | pyserial exceptions | Port opening or transport errors; propagated without wrapping |
352
+
353
+ After a timeout, close and reopen the connection: the protocol has no transaction
354
+ IDs, so a late response could be mistaken for the next one. Writes are never
355
+ retried automatically. Multiple parameter writes are not an atomic transaction.
356
+ RLock protects exchanges between threads of one instance. A port should belong
357
+ to only one instance/process.
358
+
359
+ ## Protocol ambiguities and hardware verification
360
+
361
+ * Frequency units 3/4 follow the interpretation of the examples; not hardware-verified.
362
+ * Width/period wire maxima conflict with the document's physical maxima.
363
+ Conservative limits of 0.4/4 s and steps of 1/10 ns are used.
364
+ * Command 63 is described with two fields in the text but one mode in the table.
365
+ The API uses one mode; use write_raw() for an alternative format.
366
+ * r25 accepts six compact bits or CSV. The repeated n3 in w64 is interpreted as n4.
367
+ * Maximum frequency/amplitude/positive offset/memory slot and the frequency_trim
368
+ range are not fully specified by the document.
369
+ * Initial brightness readback was 101, but writing 101 was clamped to 100 by the
370
+ device. The API write range remains 0..100; readback can exceed it.
371
+
372
+ On 2026-10-07, 13 library tests passed on Python 3.10.6 before publication preparation.
373
+ Hardware checks on `/dev/cu.usbserial-2120`, 115200, 8-N-1 covered reads, brightness
374
+ 20/100, CH2 phase, and CH1 waveform/frequency/amplitude: sine 1 kHz/2 Vpp,
375
+ square 2 kHz/4 Vpp, triangle 500 Hz/1 Vpp. The user confirmed visible changes.
376
+ Sine 3 kHz/5 Vpp and brightness 100 were restored. The analog signal was not
377
+ verified using measurement equipment.