scietex.hal.spi 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_spi-0.1.0/LICENSE +21 -0
- scietex_hal_spi-0.1.0/PKG-INFO +175 -0
- scietex_hal_spi-0.1.0/README.md +122 -0
- scietex_hal_spi-0.1.0/pyproject.toml +69 -0
- scietex_hal_spi-0.1.0/setup.cfg +4 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/__init__.py +61 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/base.py +72 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/fake.py +96 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/gpiozero_helpers.py +162 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/gpiozero_spi.py +122 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/py.typed +0 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/selection.py +82 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/spidev_spi.py +114 -0
- scietex_hal_spi-0.1.0/src/scietex/hal/spi/version.py +8 -0
- scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/PKG-INFO +175 -0
- scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/SOURCES.txt +26 -0
- scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/dependency_links.txt +1 -0
- scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/requires.txt +19 -0
- scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/top_level.txt +1 -0
- scietex_hal_spi-0.1.0/tests/test_base.py +115 -0
- scietex_hal_spi-0.1.0/tests/test_fake.py +187 -0
- scietex_hal_spi-0.1.0/tests/test_gpiozero.py +192 -0
- scietex_hal_spi-0.1.0/tests/test_gpiozero_helpers.py +171 -0
- scietex_hal_spi-0.1.0/tests/test_optional_backends.py +124 -0
- scietex_hal_spi-0.1.0/tests/test_package.py +56 -0
- scietex_hal_spi-0.1.0/tests/test_selection.py +136 -0
- scietex_hal_spi-0.1.0/tests/test_spidev.py +244 -0
- scietex_hal_spi-0.1.0/tests/test_version.py +33 -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,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scietex.hal.spi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SPI transport backends for scietex.hal devices
|
|
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.spi
|
|
28
|
+
Project-URL: Bug Tracker, https://github.com/bond-anton/scietex.hal.spi/issues
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Provides-Extra: spi
|
|
39
|
+
Requires-Dist: spidev>=3.6; extra == "spi"
|
|
40
|
+
Provides-Extra: gpiozero
|
|
41
|
+
Requires-Dist: gpiozero>=1.6.2; extra == "gpiozero"
|
|
42
|
+
Provides-Extra: test
|
|
43
|
+
Requires-Dist: pytest; extra == "test"
|
|
44
|
+
Provides-Extra: lint
|
|
45
|
+
Requires-Dist: ruff==0.17.4; extra == "lint"
|
|
46
|
+
Requires-Dist: ty==0.0.99; extra == "lint"
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: tox>=4.46.0; extra == "dev"
|
|
49
|
+
Requires-Dist: pytest; extra == "dev"
|
|
50
|
+
Requires-Dist: spidev>=3.6; extra == "dev"
|
|
51
|
+
Requires-Dist: gpiozero>=1.6.2; extra == "dev"
|
|
52
|
+
Dynamic: license-file
|
|
53
|
+
|
|
54
|
+
# scietex.hal.spi
|
|
55
|
+
|
|
56
|
+
SPI transport backends shared by the `scietex.hal.*` device distributions.
|
|
57
|
+
|
|
58
|
+
A transport is the narrow seam between a device driver and the bus. Devices
|
|
59
|
+
send whole frames through `transfer()` and release the bus with `close()`, so
|
|
60
|
+
the same driver works over `spidev`, over gpiozero's hardware SPI, or over a
|
|
61
|
+
scripted fake in tests. The package ships no device code and has no runtime
|
|
62
|
+
dependencies: the optional backends are imported only when they are used.
|
|
63
|
+
|
|
64
|
+
## The protocol
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
class SpiTransport(Protocol):
|
|
68
|
+
def transfer(self, tx: Sequence[int]) -> list[int]: ...
|
|
69
|
+
def close(self) -> None: ...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
* `transfer(tx)` sends one frame of words in `0..255` and returns the received
|
|
73
|
+
words in the same order.
|
|
74
|
+
* `close()` releases the bus handle; repeated calls have no effect. If the
|
|
75
|
+
underlying close fails, the transport stays open and `close()` can be
|
|
76
|
+
retried.
|
|
77
|
+
* `closed` is `True` once `close()` has run. For a transport that wraps a
|
|
78
|
+
caller-supplied handle, it reports that the transport itself is closed, not
|
|
79
|
+
that the shared bus was closed.
|
|
80
|
+
|
|
81
|
+
Any object with callable `transfer` and `close` methods is accepted. Use
|
|
82
|
+
`is_spi_transport(obj)` for the check: unlike `isinstance(obj, SpiTransport)`,
|
|
83
|
+
it rejects class objects. Use `SpiTransport` for typing only.
|
|
84
|
+
|
|
85
|
+
Failures raise `TransportError`, which subclasses `OSError` so callers that
|
|
86
|
+
already handle spidev or gpiozero I/O errors keep working. A transaction
|
|
87
|
+
attempted after close raises `TransportClosed`, a `TransportError` subclass.
|
|
88
|
+
|
|
89
|
+
## Backends
|
|
90
|
+
|
|
91
|
+
| Transport | Install | Notes |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `SpiDevTransport` | `pip install 'scietex.hal.spi[spi]'` | Default. Opens `/dev/spidev<bus>.<device>` through `spidev`, Linux only. |
|
|
94
|
+
| `GpiozeroSpiTransport` | `pip install 'scietex.hal.spi[gpiozero]'` | Wraps a gpiozero hardware SPI object. Resolved lazily as `scietex.hal.spi.GpiozeroSpiTransport`. |
|
|
95
|
+
| `FakeSpiTransport` | none | In-memory backend with scripted responses, for tests without hardware. |
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from scietex.hal.spi import SpiDevTransport
|
|
99
|
+
|
|
100
|
+
transport = SpiDevTransport(bus=0, device=0, max_speed_hz=500_000)
|
|
101
|
+
transport.transfer([0x00, 43])
|
|
102
|
+
transport.close()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`SpiDevTransport` transfers with `xfer2`, which keeps chip select asserted for
|
|
106
|
+
the whole frame; `xfer` would deassert it between 8-bit words.
|
|
107
|
+
|
|
108
|
+
`GpiozeroSpiTransport` builds a private shared gpiozero `SPIDevice` by default,
|
|
109
|
+
or wraps one supplied with `spi=`. On a Raspberry Pi it also patches gpiozero so
|
|
110
|
+
two hardware-SPI devices can share one port when they use different chip-select
|
|
111
|
+
lines (`fix_gpiozero_dual_cs_spi`, also exported for callers that build their
|
|
112
|
+
own gpiozero devices). Without the RPi pin backend the patch is a no-op.
|
|
113
|
+
|
|
114
|
+
A bare install ships no hardware backend; constructing one raises `ImportError`
|
|
115
|
+
naming the extra to install.
|
|
116
|
+
|
|
117
|
+
## Default selection
|
|
118
|
+
|
|
119
|
+
`build_default_transport` maps the legacy device keywords onto a backend:
|
|
120
|
+
|
|
121
|
+
| Keyword | `SpiDevTransport` | `GpiozeroSpiTransport` |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| `port` | `bus` | `port` |
|
|
124
|
+
| `device` | `device` | `device` |
|
|
125
|
+
| `clock_pin`, `mosi_pin`, `miso_pin`, `select_pin` | selects the gpiozero backend | forwarded as-is |
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from scietex.hal.spi import build_default_transport
|
|
129
|
+
|
|
130
|
+
build_default_transport(port=0, device=1) # SpiDevTransport(bus=0, device=1)
|
|
131
|
+
build_default_transport(clock_pin=11, mosi_pin=10) # GpiozeroSpiTransport(...)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Any pin keyword selects the gpiozero backend and requires the `gpiozero`
|
|
135
|
+
extra; without pin keywords the spidev backend is built with `port or 0` and
|
|
136
|
+
`device or 0`. `port` and `device` are accepted by both backends and are simply
|
|
137
|
+
forwarded.
|
|
138
|
+
|
|
139
|
+
The selector takes backend keywords only. Conflict rejection — passing backend
|
|
140
|
+
keywords together with `transport=` or `spi=` — is the responsibility of each
|
|
141
|
+
device constructor, which owns that argument surface; the selector itself raises
|
|
142
|
+
`TypeError` when given a `transport=` keyword.
|
|
143
|
+
|
|
144
|
+
## Ownership
|
|
145
|
+
|
|
146
|
+
Create one transport per device; all channels of a device share it. The device's
|
|
147
|
+
`close()` delegates to its transport.
|
|
148
|
+
|
|
149
|
+
* `SpiDevTransport` always owns and closes its spidev handle.
|
|
150
|
+
* `GpiozeroSpiTransport` closes only a handle it built. When it wraps a
|
|
151
|
+
caller-supplied `spi=` object, `close()` marks the transport closed but leaves
|
|
152
|
+
the shared bus open for its other users; the supplied handle's owner must
|
|
153
|
+
outlive the wrapper and close it. Once that owner closes the handle, transfers
|
|
154
|
+
through the wrapper raise `TransportClosed`.
|
|
155
|
+
* `FakeSpiTransport` only flips its `closed` flag.
|
|
156
|
+
|
|
157
|
+
## Linux permissions
|
|
158
|
+
|
|
159
|
+
`SpiDevTransport` needs the kernel SPI device node and permission to open it.
|
|
160
|
+
Enable kernel SPI first, for example with `dtparam=spi=on` in
|
|
161
|
+
`/boot/firmware/config.txt` on current Raspberry Pi OS (the older path is
|
|
162
|
+
`/boot/config.txt`).
|
|
163
|
+
|
|
164
|
+
If the node exists but opening fails, add a udev rule. The development setup for
|
|
165
|
+
these packages uses the `gpio` group; adjust the group to match your system::
|
|
166
|
+
|
|
167
|
+
SUBSYSTEM=="spidev", GROUP="gpio", MODE="0660"
|
|
168
|
+
|
|
169
|
+
Install the rule under `/etc/udev/rules.d/`, reload the rules, and reconnect the
|
|
170
|
+
device. The user running the process must be a member of the group named in the
|
|
171
|
+
rule; on Fedora setups that group is `gpio`, because no `spi` group exists.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
This project is licensed under the **MIT License** - see the LICENSE file for details.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# scietex.hal.spi
|
|
2
|
+
|
|
3
|
+
SPI transport backends shared by the `scietex.hal.*` device distributions.
|
|
4
|
+
|
|
5
|
+
A transport is the narrow seam between a device driver and the bus. Devices
|
|
6
|
+
send whole frames through `transfer()` and release the bus with `close()`, so
|
|
7
|
+
the same driver works over `spidev`, over gpiozero's hardware SPI, or over a
|
|
8
|
+
scripted fake in tests. The package ships no device code and has no runtime
|
|
9
|
+
dependencies: the optional backends are imported only when they are used.
|
|
10
|
+
|
|
11
|
+
## The protocol
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
class SpiTransport(Protocol):
|
|
15
|
+
def transfer(self, tx: Sequence[int]) -> list[int]: ...
|
|
16
|
+
def close(self) -> None: ...
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
* `transfer(tx)` sends one frame of words in `0..255` and returns the received
|
|
20
|
+
words in the same order.
|
|
21
|
+
* `close()` releases the bus handle; repeated calls have no effect. If the
|
|
22
|
+
underlying close fails, the transport stays open and `close()` can be
|
|
23
|
+
retried.
|
|
24
|
+
* `closed` is `True` once `close()` has run. For a transport that wraps a
|
|
25
|
+
caller-supplied handle, it reports that the transport itself is closed, not
|
|
26
|
+
that the shared bus was closed.
|
|
27
|
+
|
|
28
|
+
Any object with callable `transfer` and `close` methods is accepted. Use
|
|
29
|
+
`is_spi_transport(obj)` for the check: unlike `isinstance(obj, SpiTransport)`,
|
|
30
|
+
it rejects class objects. Use `SpiTransport` for typing only.
|
|
31
|
+
|
|
32
|
+
Failures raise `TransportError`, which subclasses `OSError` so callers that
|
|
33
|
+
already handle spidev or gpiozero I/O errors keep working. A transaction
|
|
34
|
+
attempted after close raises `TransportClosed`, a `TransportError` subclass.
|
|
35
|
+
|
|
36
|
+
## Backends
|
|
37
|
+
|
|
38
|
+
| Transport | Install | Notes |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `SpiDevTransport` | `pip install 'scietex.hal.spi[spi]'` | Default. Opens `/dev/spidev<bus>.<device>` through `spidev`, Linux only. |
|
|
41
|
+
| `GpiozeroSpiTransport` | `pip install 'scietex.hal.spi[gpiozero]'` | Wraps a gpiozero hardware SPI object. Resolved lazily as `scietex.hal.spi.GpiozeroSpiTransport`. |
|
|
42
|
+
| `FakeSpiTransport` | none | In-memory backend with scripted responses, for tests without hardware. |
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from scietex.hal.spi import SpiDevTransport
|
|
46
|
+
|
|
47
|
+
transport = SpiDevTransport(bus=0, device=0, max_speed_hz=500_000)
|
|
48
|
+
transport.transfer([0x00, 43])
|
|
49
|
+
transport.close()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`SpiDevTransport` transfers with `xfer2`, which keeps chip select asserted for
|
|
53
|
+
the whole frame; `xfer` would deassert it between 8-bit words.
|
|
54
|
+
|
|
55
|
+
`GpiozeroSpiTransport` builds a private shared gpiozero `SPIDevice` by default,
|
|
56
|
+
or wraps one supplied with `spi=`. On a Raspberry Pi it also patches gpiozero so
|
|
57
|
+
two hardware-SPI devices can share one port when they use different chip-select
|
|
58
|
+
lines (`fix_gpiozero_dual_cs_spi`, also exported for callers that build their
|
|
59
|
+
own gpiozero devices). Without the RPi pin backend the patch is a no-op.
|
|
60
|
+
|
|
61
|
+
A bare install ships no hardware backend; constructing one raises `ImportError`
|
|
62
|
+
naming the extra to install.
|
|
63
|
+
|
|
64
|
+
## Default selection
|
|
65
|
+
|
|
66
|
+
`build_default_transport` maps the legacy device keywords onto a backend:
|
|
67
|
+
|
|
68
|
+
| Keyword | `SpiDevTransport` | `GpiozeroSpiTransport` |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| `port` | `bus` | `port` |
|
|
71
|
+
| `device` | `device` | `device` |
|
|
72
|
+
| `clock_pin`, `mosi_pin`, `miso_pin`, `select_pin` | selects the gpiozero backend | forwarded as-is |
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from scietex.hal.spi import build_default_transport
|
|
76
|
+
|
|
77
|
+
build_default_transport(port=0, device=1) # SpiDevTransport(bus=0, device=1)
|
|
78
|
+
build_default_transport(clock_pin=11, mosi_pin=10) # GpiozeroSpiTransport(...)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Any pin keyword selects the gpiozero backend and requires the `gpiozero`
|
|
82
|
+
extra; without pin keywords the spidev backend is built with `port or 0` and
|
|
83
|
+
`device or 0`. `port` and `device` are accepted by both backends and are simply
|
|
84
|
+
forwarded.
|
|
85
|
+
|
|
86
|
+
The selector takes backend keywords only. Conflict rejection — passing backend
|
|
87
|
+
keywords together with `transport=` or `spi=` — is the responsibility of each
|
|
88
|
+
device constructor, which owns that argument surface; the selector itself raises
|
|
89
|
+
`TypeError` when given a `transport=` keyword.
|
|
90
|
+
|
|
91
|
+
## Ownership
|
|
92
|
+
|
|
93
|
+
Create one transport per device; all channels of a device share it. The device's
|
|
94
|
+
`close()` delegates to its transport.
|
|
95
|
+
|
|
96
|
+
* `SpiDevTransport` always owns and closes its spidev handle.
|
|
97
|
+
* `GpiozeroSpiTransport` closes only a handle it built. When it wraps a
|
|
98
|
+
caller-supplied `spi=` object, `close()` marks the transport closed but leaves
|
|
99
|
+
the shared bus open for its other users; the supplied handle's owner must
|
|
100
|
+
outlive the wrapper and close it. Once that owner closes the handle, transfers
|
|
101
|
+
through the wrapper raise `TransportClosed`.
|
|
102
|
+
* `FakeSpiTransport` only flips its `closed` flag.
|
|
103
|
+
|
|
104
|
+
## Linux permissions
|
|
105
|
+
|
|
106
|
+
`SpiDevTransport` needs the kernel SPI device node and permission to open it.
|
|
107
|
+
Enable kernel SPI first, for example with `dtparam=spi=on` in
|
|
108
|
+
`/boot/firmware/config.txt` on current Raspberry Pi OS (the older path is
|
|
109
|
+
`/boot/config.txt`).
|
|
110
|
+
|
|
111
|
+
If the node exists but opening fails, add a udev rule. The development setup for
|
|
112
|
+
these packages uses the `gpio` group; adjust the group to match your system::
|
|
113
|
+
|
|
114
|
+
SUBSYSTEM=="spidev", GROUP="gpio", MODE="0660"
|
|
115
|
+
|
|
116
|
+
Install the rule under `/etc/udev/rules.d/`, reload the rules, and reconnect the
|
|
117
|
+
device. The user running the process must be a member of the group named in the
|
|
118
|
+
rule; on Fedora setups that group is `gpio`, because no `spi` group exists.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
This project is licensed under the **MIT License** - see the LICENSE file for details.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scietex.hal.spi"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "SPI transport backends for scietex.hal devices"
|
|
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.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.10"
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
"Homepage" = "https://github.com/bond-anton/scietex.hal.spi"
|
|
25
|
+
"Bug Tracker" = "https://github.com/bond-anton/scietex.hal.spi/issues"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
spi = ["spidev>=3.6"]
|
|
29
|
+
gpiozero = ["gpiozero>=1.6.2"]
|
|
30
|
+
test = ["pytest"]
|
|
31
|
+
lint = ["ruff==0.17.4", "ty==0.0.99"]
|
|
32
|
+
dev = ["tox>=4.46.0", "pytest", "spidev>=3.6", "gpiozero>=1.6.2"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.dynamic]
|
|
35
|
+
version = { attr = "scietex.hal.spi.version.__version__" }
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
"scietex.hal.spi" = ["py.typed"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
pythonpath = [
|
|
45
|
+
"src"
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 88
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E4","E7","E9","F","W","I","B","C4","SIM","UP","PLC0415","PLR0913","PLR0917"]
|
|
54
|
+
ignore = ["E501","SLF001"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
"tests/**" = ["PLC0415"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.format]
|
|
60
|
+
quote-style = "double"
|
|
61
|
+
indent-style = "space"
|
|
62
|
+
line-ending = "auto"
|
|
63
|
+
docstring-code-format = false
|
|
64
|
+
|
|
65
|
+
[tool.ty.environment]
|
|
66
|
+
python-version = "3.10"
|
|
67
|
+
|
|
68
|
+
[tool.ty.analysis]
|
|
69
|
+
replace-imports-with-any = ["gpiozero.**", "spidev.**"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""SPI bus transport backends for scietex.hal devices."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from .base import (
|
|
6
|
+
I2cTransport,
|
|
7
|
+
SpiTransport,
|
|
8
|
+
TransportClosed,
|
|
9
|
+
TransportError,
|
|
10
|
+
is_spi_transport,
|
|
11
|
+
)
|
|
12
|
+
from .fake import FakeSpiTransport
|
|
13
|
+
from .gpiozero_helpers import fix_gpiozero_dual_cs_spi
|
|
14
|
+
from .selection import (
|
|
15
|
+
GPIOZERO_PIN_ARGS,
|
|
16
|
+
GPIOZERO_SPI_ARGS,
|
|
17
|
+
build_default_transport,
|
|
18
|
+
)
|
|
19
|
+
from .spidev_spi import SpiDevTransport
|
|
20
|
+
from .version import __version__ as __version__
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .gpiozero_spi import GpiozeroSpiTransport # noqa: F401
|
|
24
|
+
|
|
25
|
+
#: Names resolved lazily by `__getattr__`; kept out of `__all__` so a star
|
|
26
|
+
#: import works on a core install without the optional gpiozero package.
|
|
27
|
+
_LAZY_ATTRIBUTES = ("GpiozeroSpiTransport",)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"FakeSpiTransport",
|
|
31
|
+
"GPIOZERO_PIN_ARGS",
|
|
32
|
+
"GPIOZERO_SPI_ARGS",
|
|
33
|
+
"I2cTransport",
|
|
34
|
+
"SpiDevTransport",
|
|
35
|
+
"SpiTransport",
|
|
36
|
+
"TransportClosed",
|
|
37
|
+
"TransportError",
|
|
38
|
+
"build_default_transport",
|
|
39
|
+
"fix_gpiozero_dual_cs_spi",
|
|
40
|
+
"is_spi_transport",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def __getattr__(name: str) -> Any:
|
|
45
|
+
"""Resolve the optional gpiozero transport on first access."""
|
|
46
|
+
if name == "GpiozeroSpiTransport":
|
|
47
|
+
try:
|
|
48
|
+
from .gpiozero_spi import GpiozeroSpiTransport # noqa: PLC0415
|
|
49
|
+
except ImportError as exc:
|
|
50
|
+
raise AttributeError(
|
|
51
|
+
"GpiozeroSpiTransport requires the optional 'gpiozero' package. "
|
|
52
|
+
"Install it with: pip install 'scietex.hal.spi[gpiozero]'"
|
|
53
|
+
) from exc
|
|
54
|
+
|
|
55
|
+
return GpiozeroSpiTransport
|
|
56
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def __dir__() -> list[str]:
|
|
60
|
+
"""List the module attributes, including lazily resolved names."""
|
|
61
|
+
return sorted(set(__all__) | set(globals()) | set(_LAZY_ATTRIBUTES))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Transport protocols and exceptions shared by all bus backends."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TransportError(OSError):
|
|
8
|
+
"""Raised when a bus transaction fails.
|
|
9
|
+
|
|
10
|
+
Subclasses `OSError` so callers that already handle gpiozero or spidev I/O
|
|
11
|
+
errors keep catching transport failures without changes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TransportClosed(TransportError):
|
|
16
|
+
"""Raised when a transaction is attempted after close()."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class SpiTransport(Protocol):
|
|
21
|
+
"""Full-duplex SPI backend.
|
|
22
|
+
|
|
23
|
+
Structurally satisfied by any non-class object with callable `transfer`
|
|
24
|
+
and `close` methods. Use `is_spi_transport` for the check: it rejects
|
|
25
|
+
class objects, which `isinstance` against this protocol accepts.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def transfer(self, tx: Sequence[int]) -> list[int]:
|
|
29
|
+
"""Transfer one frame and return the received words."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
"""Release the bus handle."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class I2cTransport(Protocol):
|
|
39
|
+
"""I2C backend protocol for future chips.
|
|
40
|
+
|
|
41
|
+
No implementation or device uses it yet; it keeps the transport surface
|
|
42
|
+
consistent for chips with an I2C interface.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def write(self, data: Sequence[int]) -> None:
|
|
46
|
+
"""Write one frame."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def read(self, nbytes: int) -> bytes:
|
|
50
|
+
"""Read `nbytes` bytes."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def write_read(self, tx: Sequence[int], nbytes: int) -> bytes:
|
|
54
|
+
"""Write a frame, then read `nbytes` bytes."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
"""Release the bus handle."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_spi_transport(obj: object) -> bool:
|
|
63
|
+
"""Check whether `obj` structurally satisfies `SpiTransport`.
|
|
64
|
+
|
|
65
|
+
Requires callable `transfer` and `close` attributes and rejects class
|
|
66
|
+
objects, so a transport class itself does not count.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(obj, type):
|
|
69
|
+
return False
|
|
70
|
+
return callable(getattr(obj, "transfer", None)) and callable(
|
|
71
|
+
getattr(obj, "close", None)
|
|
72
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Scripted SPI transport for tests."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from .base import TransportClosed, TransportError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeSpiTransport:
|
|
9
|
+
"""SPI transport that replays scripted response frames.
|
|
10
|
+
|
|
11
|
+
Responses are consumed FIFO. `default` is returned once the queue is
|
|
12
|
+
empty; with `strict=False` an empty queue yields zero-filled frames
|
|
13
|
+
instead of an error. Request words are validated to ints in 0..255.
|
|
14
|
+
Frames are copied on the way in and out so that mutating a caller's list
|
|
15
|
+
cannot change a scripted response or a recorded call.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
responses: list[list[int]] | None = None,
|
|
21
|
+
default: list[int] | None = None,
|
|
22
|
+
strict: bool = True,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Build a scripted transport.
|
|
26
|
+
|
|
27
|
+
:param responses: Response frames consumed FIFO; each frame must have
|
|
28
|
+
the same length as the request that consumes it.
|
|
29
|
+
:param default: Frame returned once `responses` is exhausted; when
|
|
30
|
+
omitted, an exhausted queue follows `strict`.
|
|
31
|
+
:param strict: When True an empty queue with no `default` raises
|
|
32
|
+
`TransportError`; when False it returns a zero-filled frame.
|
|
33
|
+
"""
|
|
34
|
+
self._responses = (
|
|
35
|
+
[list(frame) for frame in responses] if responses is not None else []
|
|
36
|
+
)
|
|
37
|
+
self._default = list(default) if default is not None else None
|
|
38
|
+
self._strict = strict
|
|
39
|
+
self.calls: list[list[int]] = []
|
|
40
|
+
self._closed = False
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def closed(self) -> bool:
|
|
44
|
+
"""Return True once the transport has been closed."""
|
|
45
|
+
return self._closed
|
|
46
|
+
|
|
47
|
+
def queue(self, *responses: list[int]) -> None:
|
|
48
|
+
"""Append scripted response frames."""
|
|
49
|
+
self._responses.extend(list(frame) for frame in responses)
|
|
50
|
+
|
|
51
|
+
def transfer(self, tx: Sequence[int]) -> list[int]:
|
|
52
|
+
"""Return the next scripted frame for `tx`.
|
|
53
|
+
|
|
54
|
+
Request words must be ints in 0..255; a malformed word raises
|
|
55
|
+
`ValueError` before the call is recorded. A frame that fails validation
|
|
56
|
+
stays at the head of the queue and is not recorded as a call, so the
|
|
57
|
+
caller can retry with a matching request.
|
|
58
|
+
|
|
59
|
+
:param tx: Request frame.
|
|
60
|
+
:return: Response frame of the same length.
|
|
61
|
+
:raises TransportClosed: If the transport is closed.
|
|
62
|
+
:raises ValueError: If a request word is not an int in 0..255.
|
|
63
|
+
:raises TransportError: If no response is available in strict mode or
|
|
64
|
+
the scripted response length does not match the request.
|
|
65
|
+
"""
|
|
66
|
+
if self._closed:
|
|
67
|
+
raise TransportClosed("FakeSpiTransport is closed")
|
|
68
|
+
frame = list(tx)
|
|
69
|
+
for word in frame:
|
|
70
|
+
if not isinstance(word, int) or not 0 <= word <= 0xFF:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"SPI frame words must be ints in the range 0..255, got {word!r}"
|
|
73
|
+
)
|
|
74
|
+
if self._responses:
|
|
75
|
+
rx = list(self._responses[0])
|
|
76
|
+
from_queue = True
|
|
77
|
+
elif self._default is not None:
|
|
78
|
+
rx = list(self._default)
|
|
79
|
+
from_queue = False
|
|
80
|
+
elif self._strict:
|
|
81
|
+
raise TransportError("FakeSpiTransport has no scripted response")
|
|
82
|
+
else:
|
|
83
|
+
self.calls.append(frame)
|
|
84
|
+
return [0] * len(frame)
|
|
85
|
+
if len(rx) != len(frame):
|
|
86
|
+
raise TransportError(
|
|
87
|
+
f"Response length {len(rx)} does not match request length {len(frame)}"
|
|
88
|
+
)
|
|
89
|
+
if from_queue:
|
|
90
|
+
self._responses.pop(0)
|
|
91
|
+
self.calls.append(frame)
|
|
92
|
+
return rx
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
"""Close the transport. Repeated calls have no effect."""
|
|
96
|
+
self._closed = True
|