BDPotentiometer 0.1.0__tar.gz → 0.2.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.
- bdpotentiometer-0.2.0/PKG-INFO +159 -0
- bdpotentiometer-0.2.0/README.md +99 -0
- bdpotentiometer-0.2.0/pyproject.toml +72 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/__gpiozero_helpers.py +160 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/__helpers.py +173 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/__init__.py +62 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/__logger.py +81 -0
- {bdpotentiometer-0.1.0 → bdpotentiometer-0.2.0}/src/BDPotentiometer/digital_potentiometer.py +237 -128
- bdpotentiometer-0.2.0/src/BDPotentiometer/digital_wiper.py +455 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/mcp4xxx/__init__.py +43 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/mcp4xxx/mcp4xx1.py +232 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/mcp4xxx/mcp4xx2.py +232 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/mcp4xxx/mcp4xxx.py +585 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/potentiometer.py +520 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/py.typed +0 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/transport/__init__.py +58 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/transport/base.py +72 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/transport/fake.py +97 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/transport/gpiozero_spi.py +122 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/transport/selection.py +82 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer/transport/spidev_spi.py +114 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer.egg-info/PKG-INFO +159 -0
- {bdpotentiometer-0.1.0 → bdpotentiometer-0.2.0}/src/BDPotentiometer.egg-info/SOURCES.txt +11 -0
- bdpotentiometer-0.2.0/src/BDPotentiometer.egg-info/requires.txt +25 -0
- bdpotentiometer-0.2.0/tests/test_backward_compatibility.py +154 -0
- bdpotentiometer-0.2.0/tests/test_gpiozero_helpers.py +174 -0
- bdpotentiometer-0.2.0/tests/test_logger.py +114 -0
- bdpotentiometer-0.2.0/tests/test_optional_backends.py +145 -0
- {bdpotentiometer-0.1.0 → bdpotentiometer-0.2.0}/tests/test_version.py +1 -1
- bdpotentiometer-0.1.0/PKG-INFO +0 -79
- bdpotentiometer-0.1.0/README.md +0 -33
- bdpotentiometer-0.1.0/pyproject.toml +0 -47
- bdpotentiometer-0.1.0/src/BDPotentiometer/__gpiozero_helpers.py +0 -83
- bdpotentiometer-0.1.0/src/BDPotentiometer/__helpers.py +0 -160
- bdpotentiometer-0.1.0/src/BDPotentiometer/__init__.py +0 -17
- bdpotentiometer-0.1.0/src/BDPotentiometer/__logger.py +0 -25
- bdpotentiometer-0.1.0/src/BDPotentiometer/digital_wiper.py +0 -243
- bdpotentiometer-0.1.0/src/BDPotentiometer/mcp4xxx/__init__.py +0 -24
- bdpotentiometer-0.1.0/src/BDPotentiometer/mcp4xxx/mcp4xx1.py +0 -76
- bdpotentiometer-0.1.0/src/BDPotentiometer/mcp4xxx/mcp4xx2.py +0 -74
- bdpotentiometer-0.1.0/src/BDPotentiometer/mcp4xxx/mcp4xxx.py +0 -224
- bdpotentiometer-0.1.0/src/BDPotentiometer/potentiometer.py +0 -373
- bdpotentiometer-0.1.0/src/BDPotentiometer.egg-info/PKG-INFO +0 -79
- bdpotentiometer-0.1.0/src/BDPotentiometer.egg-info/requires.txt +0 -13
- {bdpotentiometer-0.1.0 → bdpotentiometer-0.2.0}/LICENSE +0 -0
- {bdpotentiometer-0.1.0 → bdpotentiometer-0.2.0}/setup.cfg +0 -0
- {bdpotentiometer-0.1.0 → bdpotentiometer-0.2.0}/src/BDPotentiometer.egg-info/dependency_links.txt +0 -0
- {bdpotentiometer-0.1.0 → bdpotentiometer-0.2.0}/src/BDPotentiometer.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: BDPotentiometer
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: BD Digital Potentiometer
|
|
5
|
+
Author-email: Anton Bondarenko <bond.anton@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2023 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/BDPotentiometer
|
|
28
|
+
Project-URL: Bug Tracker, https://github.com/bond-anton/BDPotentiometer/issues
|
|
29
|
+
Project-URL: Documentation, https://bdpotentiometer.readthedocs.io
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Requires-Python: >=3.9
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Provides-Extra: gpiozero
|
|
41
|
+
Requires-Dist: gpiozero>=1.6.2; extra == "gpiozero"
|
|
42
|
+
Provides-Extra: spi
|
|
43
|
+
Requires-Dist: spidev>=3.6; extra == "spi"
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: gpiozero>=1.6.2; extra == "dev"
|
|
46
|
+
Requires-Dist: spidev>=3.6; extra == "dev"
|
|
47
|
+
Requires-Dist: numpy; extra == "dev"
|
|
48
|
+
Provides-Extra: docs
|
|
49
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
50
|
+
Requires-Dist: sphinx_rtd_theme; extra == "docs"
|
|
51
|
+
Requires-Dist: gpiozero>=1.6.2; extra == "docs"
|
|
52
|
+
Requires-Dist: numpy; extra == "docs"
|
|
53
|
+
Provides-Extra: test
|
|
54
|
+
Requires-Dist: pytest; extra == "test"
|
|
55
|
+
Requires-Dist: numpy; extra == "test"
|
|
56
|
+
Provides-Extra: lint
|
|
57
|
+
Requires-Dist: ruff==0.17.4; extra == "lint"
|
|
58
|
+
Requires-Dist: ty==0.0.99; extra == "lint"
|
|
59
|
+
Dynamic: license-file
|
|
60
|
+
|
|
61
|
+
# BDPotentiometer
|
|
62
|
+
|
|
63
|
+

|
|
64
|
+
[](https://bdpotentiometer.readthedocs.io/en/latest/?badge=latest)
|
|
65
|
+
|
|
66
|
+
Module to operate digital potentiometers over SPI. Supports the MCP4xxx series
|
|
67
|
+
through a pluggable transport layer.
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
The bare package installs the core library only: the model classes and
|
|
72
|
+
`FakeSpiTransport` work, while building a device for real hardware raises an
|
|
73
|
+
`ImportError` naming the extra to install.
|
|
74
|
+
|
|
75
|
+
```shell
|
|
76
|
+
$ pip install BDPotentiometer
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The default hardware backend is spidev; the `spi` extra installs it:
|
|
80
|
+
```shell
|
|
81
|
+
$ pip install 'BDPotentiometer[spi]'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The optional gpiozero backend needs its own extra:
|
|
85
|
+
```shell
|
|
86
|
+
$ pip install 'BDPotentiometer[gpiozero]'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
Import the correct potentiometer class, for example MCP4231, and start operating your pot.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from BDPotentiometer.mcp4xxx import MCP4231
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
my_pot = MCP4231(r_ab=10e3, device=0)
|
|
98
|
+
my_pot.channels[0].value = 43
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`device=` is the spidev chip-select line, so this selects `SpiDevTransport` and
|
|
102
|
+
needs the `spi` extra. Pin keywords select the gpiozero backend instead:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
my_pot = MCP4231(r_ab=10e3, clock_pin=11, select_pin=8)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`value` is an integer code clamped to `0..max_code`, which equals `max_value`:
|
|
109
|
+
`0..128` on 7-bit parts and `0..256` on 8-bit parts. Fractional values and
|
|
110
|
+
booleans are rejected. Backend keywords cannot be combined with `transport=`
|
|
111
|
+
or `spi=`; doing so raises `TypeError`.
|
|
112
|
+
|
|
113
|
+
## Transports
|
|
114
|
+
|
|
115
|
+
SPI access goes through a transport object passed with `transport=`:
|
|
116
|
+
|
|
117
|
+
* `SpiDevTransport` (default) opens `/dev/spidev<bus>.<device>` through
|
|
118
|
+
`spidev`; needs the `spi` extra. `port=` maps to the bus, `device=` to the
|
|
119
|
+
chip-select line.
|
|
120
|
+
* `GpiozeroSpiTransport` (optional) wraps gpiozero hardware SPI; passing
|
|
121
|
+
`clock_pin=`, `mosi_pin=`, `miso_pin=`, or `select_pin=` builds one, and it
|
|
122
|
+
needs the `gpiozero` extra.
|
|
123
|
+
* `FakeSpiTransport` replays scripted responses, so code can run without
|
|
124
|
+
hardware or extras.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from BDPotentiometer.mcp4xxx import MCP4231
|
|
128
|
+
from BDPotentiometer.transport import SpiDevTransport
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
my_pot = MCP4231(r_ab=10e3, transport=SpiDevTransport(bus=0, device=0))
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
For tests, the fake backend needs no extra:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from BDPotentiometer.transport import FakeSpiTransport
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
my_pot = MCP4231(r_ab=10e3, transport=FakeSpiTransport(default=[0, 0]))
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`spi=` is a legacy alias for `transport=`. `MCP4xxx` is not a
|
|
144
|
+
`gpiozero.SPIDevice`; `.spi` returns the backend's raw handle (a
|
|
145
|
+
`spidev.SpiDev` or a gpiozero SPI object), and `.close()` / `.closed` release
|
|
146
|
+
and report the underlying transport.
|
|
147
|
+
|
|
148
|
+
`from BDPotentiometer import *` follows `__all__`, which includes
|
|
149
|
+
`GpiozeroSpiTransport`. That name is resolved lazily, so a star import without
|
|
150
|
+
the `gpiozero` extra raises `AttributeError`; import the names you need
|
|
151
|
+
explicitly when the optional backend may be absent.
|
|
152
|
+
|
|
153
|
+
Please see the [examples](examples) directory for more usage examples.
|
|
154
|
+
|
|
155
|
+
Detailed documentation is available at https://bdpotentiometer.readthedocs.io
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
BDPotentiometer is free open source software licensed under MIT License
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# BDPotentiometer
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
[](https://bdpotentiometer.readthedocs.io/en/latest/?badge=latest)
|
|
5
|
+
|
|
6
|
+
Module to operate digital potentiometers over SPI. Supports the MCP4xxx series
|
|
7
|
+
through a pluggable transport layer.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
The bare package installs the core library only: the model classes and
|
|
12
|
+
`FakeSpiTransport` work, while building a device for real hardware raises an
|
|
13
|
+
`ImportError` naming the extra to install.
|
|
14
|
+
|
|
15
|
+
```shell
|
|
16
|
+
$ pip install BDPotentiometer
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The default hardware backend is spidev; the `spi` extra installs it:
|
|
20
|
+
```shell
|
|
21
|
+
$ pip install 'BDPotentiometer[spi]'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The optional gpiozero backend needs its own extra:
|
|
25
|
+
```shell
|
|
26
|
+
$ pip install 'BDPotentiometer[gpiozero]'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
Import the correct potentiometer class, for example MCP4231, and start operating your pot.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from BDPotentiometer.mcp4xxx import MCP4231
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
my_pot = MCP4231(r_ab=10e3, device=0)
|
|
38
|
+
my_pot.channels[0].value = 43
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`device=` is the spidev chip-select line, so this selects `SpiDevTransport` and
|
|
42
|
+
needs the `spi` extra. Pin keywords select the gpiozero backend instead:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
my_pot = MCP4231(r_ab=10e3, clock_pin=11, select_pin=8)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`value` is an integer code clamped to `0..max_code`, which equals `max_value`:
|
|
49
|
+
`0..128` on 7-bit parts and `0..256` on 8-bit parts. Fractional values and
|
|
50
|
+
booleans are rejected. Backend keywords cannot be combined with `transport=`
|
|
51
|
+
or `spi=`; doing so raises `TypeError`.
|
|
52
|
+
|
|
53
|
+
## Transports
|
|
54
|
+
|
|
55
|
+
SPI access goes through a transport object passed with `transport=`:
|
|
56
|
+
|
|
57
|
+
* `SpiDevTransport` (default) opens `/dev/spidev<bus>.<device>` through
|
|
58
|
+
`spidev`; needs the `spi` extra. `port=` maps to the bus, `device=` to the
|
|
59
|
+
chip-select line.
|
|
60
|
+
* `GpiozeroSpiTransport` (optional) wraps gpiozero hardware SPI; passing
|
|
61
|
+
`clock_pin=`, `mosi_pin=`, `miso_pin=`, or `select_pin=` builds one, and it
|
|
62
|
+
needs the `gpiozero` extra.
|
|
63
|
+
* `FakeSpiTransport` replays scripted responses, so code can run without
|
|
64
|
+
hardware or extras.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from BDPotentiometer.mcp4xxx import MCP4231
|
|
68
|
+
from BDPotentiometer.transport import SpiDevTransport
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
my_pot = MCP4231(r_ab=10e3, transport=SpiDevTransport(bus=0, device=0))
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For tests, the fake backend needs no extra:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from BDPotentiometer.transport import FakeSpiTransport
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
my_pot = MCP4231(r_ab=10e3, transport=FakeSpiTransport(default=[0, 0]))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`spi=` is a legacy alias for `transport=`. `MCP4xxx` is not a
|
|
84
|
+
`gpiozero.SPIDevice`; `.spi` returns the backend's raw handle (a
|
|
85
|
+
`spidev.SpiDev` or a gpiozero SPI object), and `.close()` / `.closed` release
|
|
86
|
+
and report the underlying transport.
|
|
87
|
+
|
|
88
|
+
`from BDPotentiometer import *` follows `__all__`, which includes
|
|
89
|
+
`GpiozeroSpiTransport`. That name is resolved lazily, so a star import without
|
|
90
|
+
the `gpiozero` extra raises `AttributeError`; import the names you need
|
|
91
|
+
explicitly when the optional backend may be absent.
|
|
92
|
+
|
|
93
|
+
Please see the [examples](examples) directory for more usage examples.
|
|
94
|
+
|
|
95
|
+
Detailed documentation is available at https://bdpotentiometer.readthedocs.io
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
BDPotentiometer is free open source software licensed under MIT License
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "BDPotentiometer"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "BD Digital Potentiometer"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Anton Bondarenko", email = "bond.anton@gmail.com" },
|
|
11
|
+
]
|
|
12
|
+
license = { file = "LICENSE" }
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.9"
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
"Homepage" = "https://github.com/bond-anton/BDPotentiometer"
|
|
28
|
+
"Bug Tracker" = "https://github.com/bond-anton/BDPotentiometer/issues"
|
|
29
|
+
"Documentation" = "https://bdpotentiometer.readthedocs.io"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
gpiozero = ["gpiozero>=1.6.2"]
|
|
33
|
+
spi = ["spidev>=3.6"]
|
|
34
|
+
dev = ["gpiozero>=1.6.2", "spidev>=3.6", "numpy"]
|
|
35
|
+
docs = ["sphinx", "sphinx_rtd_theme", "gpiozero>=1.6.2", "numpy"]
|
|
36
|
+
test = ["pytest", "numpy"]
|
|
37
|
+
lint = ["ruff==0.17.4", "ty==0.0.99"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.dynamic]
|
|
40
|
+
version = {attr = "BDPotentiometer.__version__"}
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
BDPotentiometer = ["py.typed"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
pythonpath = [
|
|
47
|
+
"src"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 88
|
|
52
|
+
target-version = "py39"
|
|
53
|
+
extend-exclude = ["docs"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E4","E7","E9","F","W","I","B","C4","SIM","UP","PLC0415","PLR0913","PLR0917"]
|
|
57
|
+
ignore = ["E501","SLF001"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
"tests/**" = ["PLC0415"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.format]
|
|
63
|
+
quote-style = "double"
|
|
64
|
+
indent-style = "space"
|
|
65
|
+
line-ending = "auto"
|
|
66
|
+
docstring-code-format = false
|
|
67
|
+
|
|
68
|
+
[tool.ty.environment]
|
|
69
|
+
python-version = "3.9"
|
|
70
|
+
|
|
71
|
+
[tool.ty.analysis]
|
|
72
|
+
replace-imports-with-any = ["gpiozero.**", "spidev.**"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""gpiozero patch that lets two hardware-SPI devices share one SPI port.
|
|
2
|
+
|
|
3
|
+
gpiozero refuses to create a second hardware-SPI device on a port that is
|
|
4
|
+
already in use, even when the two devices use different chip-select lines (as
|
|
5
|
+
the MCP4xxx dual-potentiometer boards do). `fix_gpiozero_dual_cs_spi` replaces
|
|
6
|
+
`Device.pin_factory` with a factory whose SPI classes treat only a same port and
|
|
7
|
+
same device pair as a conflict. Without the Raspberry Pi pin backend the
|
|
8
|
+
function is a no-op.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# The factory class installed by the last successful call. It lives at module
|
|
18
|
+
# scope so a later call can recognize its own factory instead of stacking
|
|
19
|
+
# another subclass on top of it.
|
|
20
|
+
_PATCHED_FACTORY_CLS: Optional[type] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fixed_factory_for(base_factory_cls: type) -> type:
|
|
24
|
+
"""
|
|
25
|
+
Build a dual-CS factory subclass for `base_factory_cls`.
|
|
26
|
+
|
|
27
|
+
The base class is the active gpiozero factory type, so the patch keeps the
|
|
28
|
+
pin backend gpiozero already selected instead of forcing RPi.GPIO.
|
|
29
|
+
|
|
30
|
+
:param base_factory_cls: Local pin factory type to subclass.
|
|
31
|
+
:return: Factory class whose hardware-SPI classes only conflict on the
|
|
32
|
+
same port and device pair.
|
|
33
|
+
"""
|
|
34
|
+
from gpiozero.pins.local import ( # noqa: PLC0415
|
|
35
|
+
LocalPiHardwareSPI,
|
|
36
|
+
LocalPiHardwareSPIShared,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
class LocalPiHardwareSPIFixed(LocalPiHardwareSPI):
|
|
40
|
+
"""Hardware SPI that conflicts only on the same port and device."""
|
|
41
|
+
|
|
42
|
+
def _conflicts_with(self, other):
|
|
43
|
+
return not (
|
|
44
|
+
isinstance(other, LocalPiHardwareSPI)
|
|
45
|
+
and (self._port, self._device) != (other._port, other._device)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def value(self):
|
|
50
|
+
"""SPI pins carry no device state, so the value is not implemented."""
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
class LocalPiHardwareSPISharedFixed(LocalPiHardwareSPIShared):
|
|
54
|
+
"""Shared hardware SPI with the same port/device conflict rule."""
|
|
55
|
+
|
|
56
|
+
def _conflicts_with(self, other):
|
|
57
|
+
return not (
|
|
58
|
+
isinstance(other, LocalPiHardwareSPI)
|
|
59
|
+
and (self._port, self._device) != (other._port, other._device)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def value(self):
|
|
64
|
+
"""SPI pins carry no device state, so the value is not implemented."""
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
def _get_spi_class(self, shared, hardware):
|
|
68
|
+
"""
|
|
69
|
+
Return the fixed SPI class for hardware SPI.
|
|
70
|
+
|
|
71
|
+
:param shared: Flag for shared usage of the SPI port.
|
|
72
|
+
:param hardware: Flag for hardware SPI usage.
|
|
73
|
+
:return: Fixed SPI class for hardware SPI, the parent class otherwise.
|
|
74
|
+
"""
|
|
75
|
+
if hardware:
|
|
76
|
+
if shared:
|
|
77
|
+
return LocalPiHardwareSPISharedFixed
|
|
78
|
+
return LocalPiHardwareSPIFixed
|
|
79
|
+
# `base_factory_cls` is typed as plain `type` because gpiozero is an
|
|
80
|
+
# optional dependency and its factory protocol cannot be imported for
|
|
81
|
+
# static analysis.
|
|
82
|
+
factory: Any = base_factory_cls
|
|
83
|
+
return factory._get_spi_class(self, shared, hardware)
|
|
84
|
+
|
|
85
|
+
# Built with type() rather than a class statement because the base class is
|
|
86
|
+
# only known at runtime; a class statement over a variable base prevents
|
|
87
|
+
# static analysis of the MRO.
|
|
88
|
+
return type(
|
|
89
|
+
"FixedFactory",
|
|
90
|
+
(base_factory_cls,),
|
|
91
|
+
{
|
|
92
|
+
"__doc__": "Active local factory whose hardware SPI can share a port.",
|
|
93
|
+
"_get_spi_class": _get_spi_class,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fix_gpiozero_dual_cs_spi() -> None:
|
|
99
|
+
"""
|
|
100
|
+
Patch gpiozero so two hardware-SPI devices can share one SPI port.
|
|
101
|
+
|
|
102
|
+
Installs a subclass of the active `LocalPiFactory` into
|
|
103
|
+
`Device.pin_factory`. Its SPI classes report a conflict only when both the
|
|
104
|
+
port and the device (chip select) match, instead of whenever the port
|
|
105
|
+
matches. Based on https://github.com/gpiozero/gpiozero/issues/1015
|
|
106
|
+
|
|
107
|
+
The patch applies only to gpiozero's own default `RPiGPIOFactory`. A
|
|
108
|
+
factory chosen explicitly by the application is left untouched, and the
|
|
109
|
+
function returns without patching when `GPIOZERO_PIN_FACTORY` is set
|
|
110
|
+
because gpiozero honors that variable itself. A second call recognizes the
|
|
111
|
+
factory installed by the first call and is a no-op.
|
|
112
|
+
|
|
113
|
+
The gpiozero imports are deferred so that importing this module never
|
|
114
|
+
imports gpiozero; on hosts without gpiozero or its Raspberry Pi pin backend
|
|
115
|
+
the call is a no-op.
|
|
116
|
+
|
|
117
|
+
:return: None.
|
|
118
|
+
"""
|
|
119
|
+
global _PATCHED_FACTORY_CLS
|
|
120
|
+
|
|
121
|
+
if "GPIOZERO_PIN_FACTORY" in os.environ:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# Imported lazily so the core install needs no gpiozero package.
|
|
126
|
+
from gpiozero import Device # noqa: PLC0415
|
|
127
|
+
from gpiozero.pins.rpigpio import RPiGPIOFactory # noqa: PLC0415
|
|
128
|
+
except Exception as exc: # gpiozero or its pin backend is unavailable
|
|
129
|
+
_LOGGER.debug("gpiozero dual-CS patch skipped: %s", exc)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
current_factory = Device.pin_factory
|
|
133
|
+
if current_factory is None:
|
|
134
|
+
try:
|
|
135
|
+
default_factory = Device._default_pin_factory()
|
|
136
|
+
except Exception: # any backend failure means no patch
|
|
137
|
+
_LOGGER.debug(
|
|
138
|
+
"gpiozero could not choose a default pin factory", exc_info=True
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
base_factory_cls = type(default_factory)
|
|
142
|
+
if base_factory_cls is not RPiGPIOFactory:
|
|
143
|
+
_LOGGER.debug(
|
|
144
|
+
"gpiozero default factory %s is not supported; not patching",
|
|
145
|
+
base_factory_cls.__name__,
|
|
146
|
+
)
|
|
147
|
+
return
|
|
148
|
+
else:
|
|
149
|
+
base_factory_cls = type(current_factory)
|
|
150
|
+
if base_factory_cls is _PATCHED_FACTORY_CLS:
|
|
151
|
+
return
|
|
152
|
+
# Any factory the application selected is a real choice, so keep it.
|
|
153
|
+
if base_factory_cls is not RPiGPIOFactory:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if _PATCHED_FACTORY_CLS is None or not issubclass(
|
|
157
|
+
_PATCHED_FACTORY_CLS, base_factory_cls
|
|
158
|
+
):
|
|
159
|
+
_PATCHED_FACTORY_CLS = _fixed_factory_for(base_factory_cls)
|
|
160
|
+
Device.pin_factory = _PATCHED_FACTORY_CLS()
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_number(num: Union[float, int, Real]) -> Union[float, int]:
|
|
15
|
+
"""
|
|
16
|
+
Check that `num` is a finite real number.
|
|
17
|
+
|
|
18
|
+
Infinity and `NaN` are rejected. `bool` is rejected as well even though it
|
|
19
|
+
is an `int` subclass: `True` and `False` are flags, not measurements, and
|
|
20
|
+
accepting them as numbers hides caller mistakes. Both `+0.0` and `-0.0` are
|
|
21
|
+
returned as `0.0`; integers are returned as int, all other numbers as float.
|
|
22
|
+
|
|
23
|
+
:param num: variable to check.
|
|
24
|
+
:return: Unchanged `num` as int or float.
|
|
25
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
26
|
+
:raises ValueError: If `num` is infinite or `NaN`.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(num, bool):
|
|
29
|
+
raise TypeError(
|
|
30
|
+
f"Expected a finite integer or float number, not bool {num!r} "
|
|
31
|
+
f"of type {type(num)}"
|
|
32
|
+
)
|
|
33
|
+
if not isinstance(num, Real):
|
|
34
|
+
raise TypeError(f"Expected a finite integer or float number, got {type(num)}")
|
|
35
|
+
if isinstance(num, Integral):
|
|
36
|
+
return int(num)
|
|
37
|
+
if num in [float("inf"), float("-inf")]:
|
|
38
|
+
raise ValueError(f"Expected a finite integer or float number, got {num}")
|
|
39
|
+
if isnan(num):
|
|
40
|
+
raise ValueError("Expected a finite integer or float number, not NaN")
|
|
41
|
+
if num == 0:
|
|
42
|
+
return 0.0
|
|
43
|
+
return float(num)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_integer(num: Union[float, int, Real]) -> int:
|
|
47
|
+
"""
|
|
48
|
+
Check that `num` is an integer and convert it to int.
|
|
49
|
+
|
|
50
|
+
:param num: Number (either int or float) to be checked.
|
|
51
|
+
:return: int representation of `num`.
|
|
52
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
53
|
+
:raises ValueError: If `num` is infinite, `NaN`, or has a fractional part.
|
|
54
|
+
"""
|
|
55
|
+
num = check_number(num)
|
|
56
|
+
if isinstance(num, float) and not num.is_integer():
|
|
57
|
+
raise ValueError(f"Expected an integer number, got {num}")
|
|
58
|
+
return int(num)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_bool(value: bool) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Check that `value` is a boolean.
|
|
64
|
+
|
|
65
|
+
:param value: variable to check.
|
|
66
|
+
:return: Unchanged `value` as bool.
|
|
67
|
+
:raises TypeError: If `value` is not a boolean.
|
|
68
|
+
"""
|
|
69
|
+
if not isinstance(value, bool):
|
|
70
|
+
raise TypeError(f"Expected a boolean, got {value!r} of type {type(value)}")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
LABEL_MAX_LENGTH = 64
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_label(value: str) -> str:
|
|
78
|
+
"""
|
|
79
|
+
Check that `value` is a non-empty string usable as a channel label.
|
|
80
|
+
|
|
81
|
+
Non-string input is rejected instead of coerced, so a label can never be
|
|
82
|
+
confused with a channel number. Surrounding whitespace is stripped and the
|
|
83
|
+
stripped text is returned, so `" a "` and `"a"` are the same label and an
|
|
84
|
+
all-whitespace string is empty.
|
|
85
|
+
|
|
86
|
+
:param value: Label to check.
|
|
87
|
+
:return: Stripped label as str.
|
|
88
|
+
:raises TypeError: If `value` is not a str.
|
|
89
|
+
:raises ValueError: If `value` is empty after stripping, or longer than
|
|
90
|
+
`LABEL_MAX_LENGTH` characters.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(value, str):
|
|
93
|
+
raise TypeError(f"Expected a string label, got {value!r} of type {type(value)}")
|
|
94
|
+
text = value.strip()
|
|
95
|
+
if not text:
|
|
96
|
+
raise ValueError("Expected a non-empty label")
|
|
97
|
+
if len(text) > LABEL_MAX_LENGTH:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Expected a label of at most {LABEL_MAX_LENGTH} characters, "
|
|
100
|
+
f"got {len(text)}"
|
|
101
|
+
)
|
|
102
|
+
return text
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_not_negative(num: Union[float, int]) -> Union[float, int]:
|
|
106
|
+
"""
|
|
107
|
+
Check that `num` is a not negative number.
|
|
108
|
+
|
|
109
|
+
`bool` is rejected as a type error even though it is an `int` subclass; pass
|
|
110
|
+
`0` or `1` explicitly when a numeric flag is intended.
|
|
111
|
+
|
|
112
|
+
:param num: Either float or int number.
|
|
113
|
+
:return: `num` unchanged.
|
|
114
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
115
|
+
:raises ValueError: If `num` is negative, infinite, or `NaN`.
|
|
116
|
+
"""
|
|
117
|
+
num = check_number(num)
|
|
118
|
+
if num < 0:
|
|
119
|
+
raise ValueError(f"Expected non-negative number, got {num}")
|
|
120
|
+
return num
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_positive(num: Union[float, int]) -> Union[float, int]:
|
|
124
|
+
"""
|
|
125
|
+
Check that `num` is a positive number.
|
|
126
|
+
|
|
127
|
+
`bool` is rejected as a type error even though it is an `int` subclass; pass
|
|
128
|
+
`1` explicitly when a numeric flag is intended.
|
|
129
|
+
|
|
130
|
+
:param num: Either float or int number.
|
|
131
|
+
:return: `num` unchanged.
|
|
132
|
+
:raises TypeError: If `num` is not a real number, or is a `bool`.
|
|
133
|
+
:raises ValueError: If `num` is zero, negative, infinite, or `NaN`.
|
|
134
|
+
"""
|
|
135
|
+
num = check_number(num)
|
|
136
|
+
if num <= 0:
|
|
137
|
+
raise ValueError(f"Expected a positive number, got {num}")
|
|
138
|
+
return num
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def clamp(
|
|
142
|
+
value: Union[float, int], min_value: Union[float, int], max_value: Union[float, int]
|
|
143
|
+
) -> Union[float, int]:
|
|
144
|
+
"""
|
|
145
|
+
Clamp `value` to the range between `min_value` and `max_value`.
|
|
146
|
+
|
|
147
|
+
The bounds may be passed in any order: swapped bounds are normalized before
|
|
148
|
+
the comparison. A float `value` always stays a float, even when it is
|
|
149
|
+
clamped to an int bound. All three arguments reject `bool` even though it
|
|
150
|
+
is an `int` subclass; pass `0` or `1` explicitly when a numeric flag is
|
|
151
|
+
intended.
|
|
152
|
+
|
|
153
|
+
:param value: Either float or int number to be coerced.
|
|
154
|
+
:param min_value: Lower coerce range boundary.
|
|
155
|
+
:param max_value: Upper coerce range boundary.
|
|
156
|
+
:return: Value coerced to the given range.
|
|
157
|
+
:raises TypeError: If an argument is not a real number, or is a `bool`.
|
|
158
|
+
:raises ValueError: If an argument is infinite or `NaN`.
|
|
159
|
+
"""
|
|
160
|
+
value = check_number(value)
|
|
161
|
+
min_value = check_number(min_value)
|
|
162
|
+
max_value = check_number(max_value)
|
|
163
|
+
if min_value > max_value:
|
|
164
|
+
min_value, max_value = max_value, min_value
|
|
165
|
+
if value < min_value:
|
|
166
|
+
if isinstance(value, float):
|
|
167
|
+
return float(min_value)
|
|
168
|
+
return min_value
|
|
169
|
+
if value > max_value:
|
|
170
|
+
if isinstance(value, float):
|
|
171
|
+
return float(max_value)
|
|
172
|
+
return max_value
|
|
173
|
+
return value
|