scietex.hal.dac 0.1.0__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.
- scietex_hal_dac-0.1.0/LICENSE +21 -0
- scietex_hal_dac-0.1.0/PKG-INFO +230 -0
- scietex_hal_dac-0.1.0/README.md +179 -0
- scietex_hal_dac-0.1.0/pyproject.toml +67 -0
- scietex_hal_dac-0.1.0/setup.cfg +4 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/__helpers.py +195 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/__init__.py +30 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/__logger.py +80 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/channel.py +296 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/dac.py +249 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/errors.py +13 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/mcp4xxx/__init__.py +5 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/mcp4xxx/mcp4xx1.py +58 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/mcp4xxx/mcp4xxx.py +135 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/py.typed +0 -0
- scietex_hal_dac-0.1.0/src/scietex/hal/dac/version.py +8 -0
- scietex_hal_dac-0.1.0/src/scietex.hal.dac.egg-info/PKG-INFO +230 -0
- scietex_hal_dac-0.1.0/src/scietex.hal.dac.egg-info/SOURCES.txt +30 -0
- scietex_hal_dac-0.1.0/src/scietex.hal.dac.egg-info/dependency_links.txt +1 -0
- scietex_hal_dac-0.1.0/src/scietex.hal.dac.egg-info/requires.txt +18 -0
- scietex_hal_dac-0.1.0/src/scietex.hal.dac.egg-info/top_level.txt +1 -0
- scietex_hal_dac-0.1.0/tests/test_abstraction.py +324 -0
- scietex_hal_dac-0.1.0/tests/test_channel_output.py +152 -0
- scietex_hal_dac-0.1.0/tests/test_channel_reference.py +125 -0
- scietex_hal_dac-0.1.0/tests/test_channel_value.py +116 -0
- scietex_hal_dac-0.1.0/tests/test_errors.py +67 -0
- scietex_hal_dac-0.1.0/tests/test_hardware.py +30 -0
- scietex_hal_dac-0.1.0/tests/test_mcp4xxx_constructor.py +161 -0
- scietex_hal_dac-0.1.0/tests/test_mcp4xxx_geometry.py +88 -0
- scietex_hal_dac-0.1.0/tests/test_mcp4xxx_lifecycle.py +101 -0
- scietex_hal_dac-0.1.0/tests/test_mcp4xxx_write.py +182 -0
- scietex_hal_dac-0.1.0/tests/test_version.py +25 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Anton Bondarenko
|
|
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,230 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scietex.hal.dac
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Digital-to-analog converters and digital potentiometers
|
|
5
|
+
Author-email: Anton Bondarenko <bond.anton@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Anton Bondarenko
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Project-URL: Homepage, https://github.com/bond-anton/scietex.hal.dac
|
|
28
|
+
Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.hal.dac/issues
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Requires-Python: >=3.10
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE
|
|
37
|
+
Requires-Dist: BDPotentiometer<1.0.0,>=0.4.0
|
|
38
|
+
Requires-Dist: scietex.hal.spi<1.0.0,>=0.1.0
|
|
39
|
+
Provides-Extra: spi
|
|
40
|
+
Requires-Dist: scietex.hal.spi[spi]; extra == "spi"
|
|
41
|
+
Provides-Extra: gpiozero
|
|
42
|
+
Requires-Dist: scietex.hal.spi[gpiozero]; extra == "gpiozero"
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: tox>=4.46.0; extra == "dev"
|
|
45
|
+
Provides-Extra: test
|
|
46
|
+
Requires-Dist: pytest; extra == "test"
|
|
47
|
+
Provides-Extra: lint
|
|
48
|
+
Requires-Dist: ruff==0.17.4; extra == "lint"
|
|
49
|
+
Requires-Dist: ty==0.0.99; extra == "lint"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# scietex.hal.dac
|
|
53
|
+
|
|
54
|
+
Digital-to-analog converters: a `DAC` device owns its output channels, and each
|
|
55
|
+
channel converts between a raw code and a scaled output. The package ships the
|
|
56
|
+
Microchip MCP4xx1 dual digital potentiometers, exposed as grounded resistor
|
|
57
|
+
dividers driven over a plain SPI transport.
|
|
58
|
+
|
|
59
|
+
The abstraction is unit-neutral: the device reference is a full scale in volts
|
|
60
|
+
for a voltage device and in amperes for a current device, and a channel may
|
|
61
|
+
override it. Scaled outputs stay `None` while no reference is configured.
|
|
62
|
+
|
|
63
|
+
## Parts
|
|
64
|
+
|
|
65
|
+
| Part | Codes | Wiper storage |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| MCP4231 | 7-bit, `0..127` | volatile |
|
|
68
|
+
| MCP4241 | 7-bit, `0..127` | non-volatile |
|
|
69
|
+
| MCP4251 | 8-bit, `0..255` | volatile |
|
|
70
|
+
| MCP4261 | 8-bit, `0..255` | non-volatile |
|
|
71
|
+
|
|
72
|
+
The non-volatile parts keep a wiper value across power cycles, but this package
|
|
73
|
+
has no commit path: only the volatile register is written, exactly as on the
|
|
74
|
+
volatile parts.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install scietex.hal.dac[spi] # Linux spidev backend
|
|
80
|
+
pip install scietex.hal.dac[gpiozero] # gpiozero backend
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The base package installs without either backend. A device built from the
|
|
84
|
+
default keywords needs `spidev`; pin keywords (`clock_pin`, `mosi_pin`,
|
|
85
|
+
`miso_pin`, `select_pin`) need `gpiozero`. A missing backend raises
|
|
86
|
+
`ImportError` naming the extra to install.
|
|
87
|
+
|
|
88
|
+
## Wiring
|
|
89
|
+
|
|
90
|
+
| Signal | Connection |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| SCLK | SPI clock |
|
|
93
|
+
| SDI | SPI MOSI |
|
|
94
|
+
| SDO | SPI MISO |
|
|
95
|
+
| CS | SPI chip select |
|
|
96
|
+
| V_DD | Supply, 2.7 V to 5.5 V |
|
|
97
|
+
| V_SS | Ground |
|
|
98
|
+
|
|
99
|
+
Each wiper is a three-terminal divider: terminal A and terminal B are the ends
|
|
100
|
+
of the resistor, W is the wiper. One end is tied to ground and the other to the
|
|
101
|
+
reference, so the wiper voltage is a fraction of that reference.
|
|
102
|
+
|
|
103
|
+
## Usage
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from scietex.hal.dac import MCP4251
|
|
107
|
+
|
|
108
|
+
device = MCP4251(port=0, device=1, reference=3.3) # /dev/spidev0.1
|
|
109
|
+
try:
|
|
110
|
+
code = device.channel(0).value # raw 8-bit code, 0..255
|
|
111
|
+
voltage = device.channel(0).voltage
|
|
112
|
+
print(code, voltage)
|
|
113
|
+
finally:
|
|
114
|
+
device.close()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`MCP4251(port=0, device=1, reference=3.3)` is also a context manager; leaving the
|
|
118
|
+
`with` block closes the transport:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
with MCP4251(port=0, device=1, reference=3.3) as device:
|
|
122
|
+
print(device.read_all())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Constructor keywords:
|
|
126
|
+
|
|
127
|
+
| Keyword | Meaning |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `r_ab` | Total resistance in ohms; 5k, 10k, 50k, or 100k (default 10k) |
|
|
130
|
+
| `channels` | Channels to expose, 1..2; omitted uses both |
|
|
131
|
+
| `reference` | Full-scale voltage in volts, or omitted while unknown |
|
|
132
|
+
| `grounded` | Terminal tied to ground, `"B"` (default) or `"A"` |
|
|
133
|
+
| `transport` (alias `spi`) | Any object with `transfer(tx)` and `close()` |
|
|
134
|
+
| `port`, `device` | spidev bus and chip select |
|
|
135
|
+
| `clock_pin`, `mosi_pin`, `miso_pin`, `select_pin` | gpiozero pin wiring |
|
|
136
|
+
| `label`, `log_level` | Logger name and level |
|
|
137
|
+
|
|
138
|
+
`invert` and `max_value` are not part of the constructor: the wiper is always
|
|
139
|
+
built uninverted, and the code range is fixed by the part. Passing either
|
|
140
|
+
keyword raises `TypeError`.
|
|
141
|
+
|
|
142
|
+
## Codes and outputs
|
|
143
|
+
|
|
144
|
+
`channel.value` is the raw code the wiper register holds. It is a live read:
|
|
145
|
+
every access is a new bus transaction, nothing is cached. The range is
|
|
146
|
+
`0..max_code`, which is `127` on a 7-bit part and `255` on an 8-bit part.
|
|
147
|
+
|
|
148
|
+
`channel.output` scales that code by the reference (grounded B shown here; see
|
|
149
|
+
[Grounding](#grounding)):
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
output = code / max_code * reference
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
With no reference configured, `output` is `None` — the library will not guess a
|
|
156
|
+
reference. Setting `device.reference = 3.3` later starts scaling new readings;
|
|
157
|
+
the codes themselves never change. `device.v_ref` and `channel.v_ref` are
|
|
158
|
+
aliases.
|
|
159
|
+
|
|
160
|
+
The setter clamps instead of rejecting: an output below zero becomes the bottom
|
|
161
|
+
of the scale (code 0 when grounded B) and an output above the reference becomes
|
|
162
|
+
the top code. Setting `output` while no reference is configured raises
|
|
163
|
+
`DACReferenceError`.
|
|
164
|
+
|
|
165
|
+
## Grounding
|
|
166
|
+
|
|
167
|
+
`grounded="B"` makes the output rise with the code: code 0 is 0 V, the top code
|
|
168
|
+
is the reference. `grounded="A"` mirrors the curve: code 0 is the reference and
|
|
169
|
+
the top code is 0 V. The raw `value` is the same under either wiring; only the
|
|
170
|
+
scaled `output` changes direction.
|
|
171
|
+
|
|
172
|
+
## Geometry
|
|
173
|
+
|
|
174
|
+
| Part | Channels | Resolution | Top code |
|
|
175
|
+
| --- | --- | --- | --- |
|
|
176
|
+
| MCP4231 | 2 | 7 | 127 |
|
|
177
|
+
| MCP4241 | 2 | 7 | 127 |
|
|
178
|
+
| MCP4251 | 2 | 8 | 255 |
|
|
179
|
+
| MCP4261 | 2 | 8 | 255 |
|
|
180
|
+
|
|
181
|
+
`device.channels` is a tuple in index order and `channel(n)` raises `IndexError`
|
|
182
|
+
outside the range. A part built with `channels=1` exposes channel 0 only.
|
|
183
|
+
|
|
184
|
+
## Reading and writing
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
codes = device.read_all() # one code per channel
|
|
188
|
+
written = device.write_all((0, 128))
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Both return tuples in index order. Every element is a separate bus transaction —
|
|
192
|
+
the calls read and write the device, they do not replay a cached buffer.
|
|
193
|
+
|
|
194
|
+
A single write is two transactions: the wiper command followed by a register
|
|
195
|
+
read-back, so the value a channel reports afterwards is what the device
|
|
196
|
+
returned, not what the caller submitted. `write_all` validates the whole
|
|
197
|
+
sequence before the first transaction, so a rejected code cannot leave the
|
|
198
|
+
device partially updated.
|
|
199
|
+
|
|
200
|
+
## Errors
|
|
201
|
+
|
|
202
|
+
- `TransportError` and `TransportClosed` (from `scietex.hal.spi`) mean the bus
|
|
203
|
+
transaction failed or the transport was already closed.
|
|
204
|
+
- `DACCodeError` (a `ValueError`) means a raw code is outside `0..max_code`.
|
|
205
|
+
- `DACReferenceError` (a `ValueError`) means an output was requested while no
|
|
206
|
+
reference is configured.
|
|
207
|
+
- A malformed device answer is rejected by the wrapped potentiometer with a
|
|
208
|
+
`ValueError` naming the frame.
|
|
209
|
+
|
|
210
|
+
## Testing
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
pytest # unit tests, no hardware needed
|
|
214
|
+
RUN_HARDWARE_TESTS=1 pytest tests/test_hardware.py
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The unit tests drive a scripted transport and never open a real bus. The
|
|
218
|
+
hardware test needs an MCP4251 on `/dev/spidev0.1` and is skipped unless
|
|
219
|
+
`RUN_HARDWARE_TESTS=1` is set; CI never enables it.
|
|
220
|
+
|
|
221
|
+
Other checks:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
tox -e format,lint,type
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
This project is licensed under the **MIT License** - see the LICENSE file for
|
|
230
|
+
details.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# scietex.hal.dac
|
|
2
|
+
|
|
3
|
+
Digital-to-analog converters: a `DAC` device owns its output channels, and each
|
|
4
|
+
channel converts between a raw code and a scaled output. The package ships the
|
|
5
|
+
Microchip MCP4xx1 dual digital potentiometers, exposed as grounded resistor
|
|
6
|
+
dividers driven over a plain SPI transport.
|
|
7
|
+
|
|
8
|
+
The abstraction is unit-neutral: the device reference is a full scale in volts
|
|
9
|
+
for a voltage device and in amperes for a current device, and a channel may
|
|
10
|
+
override it. Scaled outputs stay `None` while no reference is configured.
|
|
11
|
+
|
|
12
|
+
## Parts
|
|
13
|
+
|
|
14
|
+
| Part | Codes | Wiper storage |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| MCP4231 | 7-bit, `0..127` | volatile |
|
|
17
|
+
| MCP4241 | 7-bit, `0..127` | non-volatile |
|
|
18
|
+
| MCP4251 | 8-bit, `0..255` | volatile |
|
|
19
|
+
| MCP4261 | 8-bit, `0..255` | non-volatile |
|
|
20
|
+
|
|
21
|
+
The non-volatile parts keep a wiper value across power cycles, but this package
|
|
22
|
+
has no commit path: only the volatile register is written, exactly as on the
|
|
23
|
+
volatile parts.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install scietex.hal.dac[spi] # Linux spidev backend
|
|
29
|
+
pip install scietex.hal.dac[gpiozero] # gpiozero backend
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The base package installs without either backend. A device built from the
|
|
33
|
+
default keywords needs `spidev`; pin keywords (`clock_pin`, `mosi_pin`,
|
|
34
|
+
`miso_pin`, `select_pin`) need `gpiozero`. A missing backend raises
|
|
35
|
+
`ImportError` naming the extra to install.
|
|
36
|
+
|
|
37
|
+
## Wiring
|
|
38
|
+
|
|
39
|
+
| Signal | Connection |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| SCLK | SPI clock |
|
|
42
|
+
| SDI | SPI MOSI |
|
|
43
|
+
| SDO | SPI MISO |
|
|
44
|
+
| CS | SPI chip select |
|
|
45
|
+
| V_DD | Supply, 2.7 V to 5.5 V |
|
|
46
|
+
| V_SS | Ground |
|
|
47
|
+
|
|
48
|
+
Each wiper is a three-terminal divider: terminal A and terminal B are the ends
|
|
49
|
+
of the resistor, W is the wiper. One end is tied to ground and the other to the
|
|
50
|
+
reference, so the wiper voltage is a fraction of that reference.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from scietex.hal.dac import MCP4251
|
|
56
|
+
|
|
57
|
+
device = MCP4251(port=0, device=1, reference=3.3) # /dev/spidev0.1
|
|
58
|
+
try:
|
|
59
|
+
code = device.channel(0).value # raw 8-bit code, 0..255
|
|
60
|
+
voltage = device.channel(0).voltage
|
|
61
|
+
print(code, voltage)
|
|
62
|
+
finally:
|
|
63
|
+
device.close()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`MCP4251(port=0, device=1, reference=3.3)` is also a context manager; leaving the
|
|
67
|
+
`with` block closes the transport:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
with MCP4251(port=0, device=1, reference=3.3) as device:
|
|
71
|
+
print(device.read_all())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Constructor keywords:
|
|
75
|
+
|
|
76
|
+
| Keyword | Meaning |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `r_ab` | Total resistance in ohms; 5k, 10k, 50k, or 100k (default 10k) |
|
|
79
|
+
| `channels` | Channels to expose, 1..2; omitted uses both |
|
|
80
|
+
| `reference` | Full-scale voltage in volts, or omitted while unknown |
|
|
81
|
+
| `grounded` | Terminal tied to ground, `"B"` (default) or `"A"` |
|
|
82
|
+
| `transport` (alias `spi`) | Any object with `transfer(tx)` and `close()` |
|
|
83
|
+
| `port`, `device` | spidev bus and chip select |
|
|
84
|
+
| `clock_pin`, `mosi_pin`, `miso_pin`, `select_pin` | gpiozero pin wiring |
|
|
85
|
+
| `label`, `log_level` | Logger name and level |
|
|
86
|
+
|
|
87
|
+
`invert` and `max_value` are not part of the constructor: the wiper is always
|
|
88
|
+
built uninverted, and the code range is fixed by the part. Passing either
|
|
89
|
+
keyword raises `TypeError`.
|
|
90
|
+
|
|
91
|
+
## Codes and outputs
|
|
92
|
+
|
|
93
|
+
`channel.value` is the raw code the wiper register holds. It is a live read:
|
|
94
|
+
every access is a new bus transaction, nothing is cached. The range is
|
|
95
|
+
`0..max_code`, which is `127` on a 7-bit part and `255` on an 8-bit part.
|
|
96
|
+
|
|
97
|
+
`channel.output` scales that code by the reference (grounded B shown here; see
|
|
98
|
+
[Grounding](#grounding)):
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
output = code / max_code * reference
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
With no reference configured, `output` is `None` — the library will not guess a
|
|
105
|
+
reference. Setting `device.reference = 3.3` later starts scaling new readings;
|
|
106
|
+
the codes themselves never change. `device.v_ref` and `channel.v_ref` are
|
|
107
|
+
aliases.
|
|
108
|
+
|
|
109
|
+
The setter clamps instead of rejecting: an output below zero becomes the bottom
|
|
110
|
+
of the scale (code 0 when grounded B) and an output above the reference becomes
|
|
111
|
+
the top code. Setting `output` while no reference is configured raises
|
|
112
|
+
`DACReferenceError`.
|
|
113
|
+
|
|
114
|
+
## Grounding
|
|
115
|
+
|
|
116
|
+
`grounded="B"` makes the output rise with the code: code 0 is 0 V, the top code
|
|
117
|
+
is the reference. `grounded="A"` mirrors the curve: code 0 is the reference and
|
|
118
|
+
the top code is 0 V. The raw `value` is the same under either wiring; only the
|
|
119
|
+
scaled `output` changes direction.
|
|
120
|
+
|
|
121
|
+
## Geometry
|
|
122
|
+
|
|
123
|
+
| Part | Channels | Resolution | Top code |
|
|
124
|
+
| --- | --- | --- | --- |
|
|
125
|
+
| MCP4231 | 2 | 7 | 127 |
|
|
126
|
+
| MCP4241 | 2 | 7 | 127 |
|
|
127
|
+
| MCP4251 | 2 | 8 | 255 |
|
|
128
|
+
| MCP4261 | 2 | 8 | 255 |
|
|
129
|
+
|
|
130
|
+
`device.channels` is a tuple in index order and `channel(n)` raises `IndexError`
|
|
131
|
+
outside the range. A part built with `channels=1` exposes channel 0 only.
|
|
132
|
+
|
|
133
|
+
## Reading and writing
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
codes = device.read_all() # one code per channel
|
|
137
|
+
written = device.write_all((0, 128))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Both return tuples in index order. Every element is a separate bus transaction —
|
|
141
|
+
the calls read and write the device, they do not replay a cached buffer.
|
|
142
|
+
|
|
143
|
+
A single write is two transactions: the wiper command followed by a register
|
|
144
|
+
read-back, so the value a channel reports afterwards is what the device
|
|
145
|
+
returned, not what the caller submitted. `write_all` validates the whole
|
|
146
|
+
sequence before the first transaction, so a rejected code cannot leave the
|
|
147
|
+
device partially updated.
|
|
148
|
+
|
|
149
|
+
## Errors
|
|
150
|
+
|
|
151
|
+
- `TransportError` and `TransportClosed` (from `scietex.hal.spi`) mean the bus
|
|
152
|
+
transaction failed or the transport was already closed.
|
|
153
|
+
- `DACCodeError` (a `ValueError`) means a raw code is outside `0..max_code`.
|
|
154
|
+
- `DACReferenceError` (a `ValueError`) means an output was requested while no
|
|
155
|
+
reference is configured.
|
|
156
|
+
- A malformed device answer is rejected by the wrapped potentiometer with a
|
|
157
|
+
`ValueError` naming the frame.
|
|
158
|
+
|
|
159
|
+
## Testing
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
pytest # unit tests, no hardware needed
|
|
163
|
+
RUN_HARDWARE_TESTS=1 pytest tests/test_hardware.py
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The unit tests drive a scripted transport and never open a real bus. The
|
|
167
|
+
hardware test needs an MCP4251 on `/dev/spidev0.1` and is skipped unless
|
|
168
|
+
`RUN_HARDWARE_TESTS=1` is set; CI never enables it.
|
|
169
|
+
|
|
170
|
+
Other checks:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
tox -e format,lint,type
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
This project is licensed under the **MIT License** - see the LICENSE file for
|
|
179
|
+
details.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scietex.hal.dac"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Digital-to-analog converters and digital potentiometers"
|
|
9
|
+
authors = [{ name = "Anton Bondarenko", email = "bond.anton@gmail.com" }]
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
requires-python = ">=3.10"
|
|
20
|
+
dependencies = ["BDPotentiometer >= 0.4.0, < 1.0.0", "scietex.hal.spi >= 0.1.0, < 1.0.0"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
"Homepage" = "https://github.com/bond-anton/scietex.hal.dac"
|
|
24
|
+
"Bug Tracker" = "https://github.com/bond-anton/scietex.hal.dac/issues"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
spi = ["scietex.hal.spi[spi]"]
|
|
28
|
+
gpiozero = ["scietex.hal.spi[gpiozero]"]
|
|
29
|
+
dev = ["tox>=4.46.0"]
|
|
30
|
+
test = ["pytest"]
|
|
31
|
+
lint = ["ruff==0.17.4", "ty==0.0.99"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.dynamic]
|
|
34
|
+
version = { attr = "scietex.hal.dac.version.__version__" }
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
"scietex.hal.dac" = ["py.typed"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
pythonpath = ["src"]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 88
|
|
48
|
+
target-version = "py310"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E4","E7","E9","F","W","I","B","C4","SIM","UP","PLC0415","PLR0913","PLR0917"]
|
|
52
|
+
ignore = ["E501","SLF001"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.per-file-ignores]
|
|
55
|
+
"tests/**" = ["PLC0415"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.format]
|
|
58
|
+
quote-style = "double"
|
|
59
|
+
indent-style = "space"
|
|
60
|
+
line-ending = "auto"
|
|
61
|
+
docstring-code-format = false
|
|
62
|
+
|
|
63
|
+
[tool.ty.environment]
|
|
64
|
+
python-version = "3.10"
|
|
65
|
+
|
|
66
|
+
[tool.ty.analysis]
|
|
67
|
+
replace-imports-with-any = ["spidev.**", "gpiozero.**"]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Internal validation helpers shared by the package.
|
|
2
|
+
|
|
3
|
+
These functions are not part of the public API. They check the type of an
|
|
4
|
+
argument first, raising `TypeError` for anything that is not a real number, and
|
|
5
|
+
its value second, raising `ValueError` for infinities, `NaN`, and values outside
|
|
6
|
+
the requested range.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from math import isnan
|
|
10
|
+
from numbers import Integral, Real
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_number(num: float | int | Real) -> float | int:
|
|
14
|
+
"""
|
|
15
|
+
Check that `num` is a finite real number.
|
|
16
|
+
|
|
17
|
+
Infinity and `NaN` are rejected. `bool` is rejected as well even though it
|
|
18
|
+
is an `int` subclass: `True` and `False` are flags, not measurements, and
|
|
19
|
+
accepting them as numbers hides caller mistakes. Both `+0.0` and `-0.0` are
|
|
20
|
+
returned as `0.0`; integers are returned as int, all other numbers as float.
|
|
21
|
+
|
|
22
|
+
:param num: variable to check.
|
|
23
|
+
:return: Unchanged `num` as int or float.
|
|
24
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
25
|
+
:raises ValueError: If `num` is infinite or `NaN`.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(num, bool):
|
|
28
|
+
raise TypeError(
|
|
29
|
+
f"Expected a finite integer or float number, not bool {num!r} "
|
|
30
|
+
f"of type {type(num)}"
|
|
31
|
+
)
|
|
32
|
+
if not isinstance(num, Real):
|
|
33
|
+
raise TypeError(f"Expected a finite integer or float number, got {type(num)}")
|
|
34
|
+
if isinstance(num, Integral):
|
|
35
|
+
return int(num)
|
|
36
|
+
if num in [float("inf"), float("-inf")]:
|
|
37
|
+
raise ValueError(f"Expected a finite integer or float number, got {num}")
|
|
38
|
+
if isnan(num):
|
|
39
|
+
raise ValueError("Expected a finite integer or float number, not NaN")
|
|
40
|
+
if num == 0:
|
|
41
|
+
return 0.0
|
|
42
|
+
return float(num)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_integer(num: float | int | Real) -> int:
|
|
46
|
+
"""
|
|
47
|
+
Check that `num` is an integer and convert it to int.
|
|
48
|
+
|
|
49
|
+
:param num: Number (either int or float) to be checked.
|
|
50
|
+
:return: int representation of `num`.
|
|
51
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
52
|
+
:raises ValueError: If `num` is infinite, `NaN`, or has a fractional part.
|
|
53
|
+
"""
|
|
54
|
+
num = check_number(num)
|
|
55
|
+
if isinstance(num, float) and not num.is_integer():
|
|
56
|
+
raise ValueError(f"Expected an integer number, got {num}")
|
|
57
|
+
return int(num)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_bool(value: bool) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Check that `value` is a boolean.
|
|
63
|
+
|
|
64
|
+
:param value: variable to check.
|
|
65
|
+
:return: Unchanged `value` as bool.
|
|
66
|
+
:raises TypeError: If `value` is not a boolean.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(value, bool):
|
|
69
|
+
raise TypeError(f"Expected a boolean, got {value!r} of type {type(value)}")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
LABEL_MAX_LENGTH = 64
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_label(value: str) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Check that `value` is a non-empty string usable as a channel label.
|
|
79
|
+
|
|
80
|
+
Non-string input is rejected instead of coerced, so a label can never be
|
|
81
|
+
confused with a channel number. Surrounding whitespace is stripped and the
|
|
82
|
+
stripped text is returned, so `" a "` and `"a"` are the same label and an
|
|
83
|
+
all-whitespace string is empty.
|
|
84
|
+
|
|
85
|
+
:param value: Label to check.
|
|
86
|
+
:return: Stripped label as str.
|
|
87
|
+
:raises TypeError: If `value` is not a str.
|
|
88
|
+
:raises ValueError: If `value` is empty after stripping, or longer than
|
|
89
|
+
`LABEL_MAX_LENGTH` characters.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(value, str):
|
|
92
|
+
raise TypeError(f"Expected a string label, got {value!r} of type {type(value)}")
|
|
93
|
+
text = value.strip()
|
|
94
|
+
if not text:
|
|
95
|
+
raise ValueError("Expected a non-empty label")
|
|
96
|
+
if len(text) > LABEL_MAX_LENGTH:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Expected a label of at most {LABEL_MAX_LENGTH} characters, "
|
|
99
|
+
f"got {len(text)}"
|
|
100
|
+
)
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_positive(num: float | int) -> float | int:
|
|
105
|
+
"""
|
|
106
|
+
Check that `num` is a positive number.
|
|
107
|
+
|
|
108
|
+
`bool` is rejected as a type error even though it is an `int` subclass; pass
|
|
109
|
+
`1` explicitly when a numeric flag is intended.
|
|
110
|
+
|
|
111
|
+
:param num: Either float or int number.
|
|
112
|
+
:return: `num` unchanged.
|
|
113
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
114
|
+
:raises ValueError: If `num` is zero, negative, infinite, or `NaN`.
|
|
115
|
+
"""
|
|
116
|
+
num = check_number(num)
|
|
117
|
+
if num <= 0:
|
|
118
|
+
raise ValueError(f"Expected a positive number, got {num}")
|
|
119
|
+
return num
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def clamp(
|
|
123
|
+
value: float | int, min_value: float | int, max_value: float | int
|
|
124
|
+
) -> float | int:
|
|
125
|
+
"""
|
|
126
|
+
Clamp `value` to the range between `min_value` and `max_value`.
|
|
127
|
+
|
|
128
|
+
The bounds may be passed in any order: swapped bounds are normalized before
|
|
129
|
+
the comparison. A float `value` always stays a float, even when it is
|
|
130
|
+
clamped to an int bound. All three arguments reject `bool` even though it
|
|
131
|
+
is an `int` subclass; pass `0` or `1` explicitly when a numeric flag is
|
|
132
|
+
intended.
|
|
133
|
+
|
|
134
|
+
:param value: Either float or int number to be coerced.
|
|
135
|
+
:param min_value: Lower coerce range boundary.
|
|
136
|
+
:param max_value: Upper coerce range boundary.
|
|
137
|
+
:return: Value coerced to the given range.
|
|
138
|
+
:raises TypeError: If an argument is not a real number, or is a `bool`.
|
|
139
|
+
:raises ValueError: If an argument is infinite or `NaN`.
|
|
140
|
+
"""
|
|
141
|
+
value = check_number(value)
|
|
142
|
+
min_value = check_number(min_value)
|
|
143
|
+
max_value = check_number(max_value)
|
|
144
|
+
if min_value > max_value:
|
|
145
|
+
min_value, max_value = max_value, min_value
|
|
146
|
+
if value < min_value:
|
|
147
|
+
if isinstance(value, float):
|
|
148
|
+
return float(min_value)
|
|
149
|
+
return min_value
|
|
150
|
+
if value > max_value:
|
|
151
|
+
if isinstance(value, float):
|
|
152
|
+
return float(max_value)
|
|
153
|
+
return max_value
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_reference(value: float | None) -> float | None:
|
|
158
|
+
"""
|
|
159
|
+
Check an optional reference: `None` passes through, a number must be positive.
|
|
160
|
+
|
|
161
|
+
The reference is unit-neutral: it is a full-scale value in volts for a
|
|
162
|
+
voltage output and in amperes for a current output, so only its positivity
|
|
163
|
+
is enforced here.
|
|
164
|
+
|
|
165
|
+
:param value: Reference value, or None when the scaling is unknown.
|
|
166
|
+
:return: `None`, or the reference as float.
|
|
167
|
+
:raises TypeError: If `value` is not a real number, or is a `bool`.
|
|
168
|
+
:raises ValueError: If `value` is zero, negative, infinite, or `NaN`.
|
|
169
|
+
"""
|
|
170
|
+
if value is None:
|
|
171
|
+
return None
|
|
172
|
+
return float(check_positive(value))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def check_grounded(value: str) -> str:
|
|
176
|
+
"""
|
|
177
|
+
Check the terminal a divider output is grounded at.
|
|
178
|
+
|
|
179
|
+
Only `"A"` and `"B"` are meaningful: grounding B makes the output rise with
|
|
180
|
+
the code, grounding A makes it fall. Anything else is rejected instead of
|
|
181
|
+
coerced, so a typo cannot silently pick a direction.
|
|
182
|
+
|
|
183
|
+
:param value: Terminal name, either `"A"` or `"B"`.
|
|
184
|
+
:return: Unchanged terminal name.
|
|
185
|
+
:raises TypeError: If `value` is not a str.
|
|
186
|
+
:raises ValueError: If `value` is neither `"A"` nor `"B"`.
|
|
187
|
+
"""
|
|
188
|
+
if not isinstance(value, str):
|
|
189
|
+
raise TypeError(
|
|
190
|
+
f'Expected "A" or "B" as a grounded terminal, '
|
|
191
|
+
f"got {value!r} of type {type(value)}"
|
|
192
|
+
)
|
|
193
|
+
if value not in ("A", "B"):
|
|
194
|
+
raise ValueError(f'Expected "A" or "B" as a grounded terminal, got {value!r}')
|
|
195
|
+
return value
|