scietex.hal.spi 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scietex/hal/spi/__init__.py +61 -0
- scietex/hal/spi/base.py +72 -0
- scietex/hal/spi/fake.py +96 -0
- scietex/hal/spi/gpiozero_helpers.py +162 -0
- scietex/hal/spi/gpiozero_spi.py +122 -0
- scietex/hal/spi/py.typed +0 -0
- scietex/hal/spi/selection.py +82 -0
- scietex/hal/spi/spidev_spi.py +114 -0
- scietex/hal/spi/version.py +8 -0
- scietex_hal_spi-0.1.0.dist-info/METADATA +175 -0
- scietex_hal_spi-0.1.0.dist-info/RECORD +14 -0
- scietex_hal_spi-0.1.0.dist-info/WHEEL +5 -0
- scietex_hal_spi-0.1.0.dist-info/licenses/LICENSE +21 -0
- scietex_hal_spi-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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))
|
scietex/hal/spi/base.py
ADDED
|
@@ -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
|
+
)
|
scietex/hal/spi/fake.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
|
14
|
+
|
|
15
|
+
__all__ = ["fix_gpiozero_dual_cs_spi"]
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# The factory class installed by the last successful call. It lives at module
|
|
20
|
+
# scope so a later call can recognize its own factory instead of stacking
|
|
21
|
+
# another subclass on top of it.
|
|
22
|
+
_PATCHED_FACTORY_CLS: type | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fixed_factory_for(base_factory_cls: type) -> type:
|
|
26
|
+
"""
|
|
27
|
+
Build a dual-CS factory subclass for `base_factory_cls`.
|
|
28
|
+
|
|
29
|
+
The base class is the active gpiozero factory type, so the patch keeps the
|
|
30
|
+
pin backend gpiozero already selected instead of forcing RPi.GPIO.
|
|
31
|
+
|
|
32
|
+
:param base_factory_cls: Local pin factory type to subclass.
|
|
33
|
+
:return: Factory class whose hardware-SPI classes only conflict on the
|
|
34
|
+
same port and device pair.
|
|
35
|
+
"""
|
|
36
|
+
from gpiozero.pins.local import ( # noqa: PLC0415
|
|
37
|
+
LocalPiHardwareSPI,
|
|
38
|
+
LocalPiHardwareSPIShared,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
class LocalPiHardwareSPIFixed(LocalPiHardwareSPI):
|
|
42
|
+
"""Hardware SPI that conflicts only on the same port and device."""
|
|
43
|
+
|
|
44
|
+
def _conflicts_with(self, other):
|
|
45
|
+
return not (
|
|
46
|
+
isinstance(other, LocalPiHardwareSPI)
|
|
47
|
+
and (self._port, self._device) != (other._port, other._device)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def value(self):
|
|
52
|
+
"""SPI pins carry no device state, so the value is not implemented."""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
class LocalPiHardwareSPISharedFixed(LocalPiHardwareSPIShared):
|
|
56
|
+
"""Shared hardware SPI with the same port/device conflict rule."""
|
|
57
|
+
|
|
58
|
+
def _conflicts_with(self, other):
|
|
59
|
+
return not (
|
|
60
|
+
isinstance(other, LocalPiHardwareSPI)
|
|
61
|
+
and (self._port, self._device) != (other._port, other._device)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def value(self):
|
|
66
|
+
"""SPI pins carry no device state, so the value is not implemented."""
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
|
|
69
|
+
def _get_spi_class(self, shared, hardware):
|
|
70
|
+
"""
|
|
71
|
+
Return the fixed SPI class for hardware SPI.
|
|
72
|
+
|
|
73
|
+
:param shared: Flag for shared usage of the SPI port.
|
|
74
|
+
:param hardware: Flag for hardware SPI usage.
|
|
75
|
+
:return: Fixed SPI class for hardware SPI, the parent class otherwise.
|
|
76
|
+
"""
|
|
77
|
+
if hardware:
|
|
78
|
+
if shared:
|
|
79
|
+
return LocalPiHardwareSPISharedFixed
|
|
80
|
+
return LocalPiHardwareSPIFixed
|
|
81
|
+
# `base_factory_cls` is typed as plain `type` because gpiozero is an
|
|
82
|
+
# optional dependency and its factory protocol cannot be imported for
|
|
83
|
+
# static analysis.
|
|
84
|
+
factory: Any = base_factory_cls
|
|
85
|
+
return factory._get_spi_class(self, shared, hardware)
|
|
86
|
+
|
|
87
|
+
# Built with type() rather than a class statement because the base class is
|
|
88
|
+
# only known at runtime; a class statement over a variable base prevents
|
|
89
|
+
# static analysis of the MRO.
|
|
90
|
+
return type(
|
|
91
|
+
"FixedFactory",
|
|
92
|
+
(base_factory_cls,),
|
|
93
|
+
{
|
|
94
|
+
"__doc__": "Active local factory whose hardware SPI can share a port.",
|
|
95
|
+
"_get_spi_class": _get_spi_class,
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fix_gpiozero_dual_cs_spi() -> None:
|
|
101
|
+
"""
|
|
102
|
+
Patch gpiozero so two hardware-SPI devices can share one SPI port.
|
|
103
|
+
|
|
104
|
+
Installs a subclass of the active `LocalPiFactory` into
|
|
105
|
+
`Device.pin_factory`. Its SPI classes report a conflict only when both the
|
|
106
|
+
port and the device (chip select) match, instead of whenever the port
|
|
107
|
+
matches. Based on https://github.com/gpiozero/gpiozero/issues/1015
|
|
108
|
+
|
|
109
|
+
The patch applies only to gpiozero's own default `RPiGPIOFactory`. A
|
|
110
|
+
factory chosen explicitly by the application is left untouched, and the
|
|
111
|
+
function returns without patching when `GPIOZERO_PIN_FACTORY` is set
|
|
112
|
+
because gpiozero honors that variable itself. A second call recognizes the
|
|
113
|
+
factory installed by the first call and is a no-op.
|
|
114
|
+
|
|
115
|
+
The gpiozero imports are deferred so that importing this module never
|
|
116
|
+
imports gpiozero; on hosts without gpiozero or its Raspberry Pi pin backend
|
|
117
|
+
the call is a no-op.
|
|
118
|
+
|
|
119
|
+
:return: None.
|
|
120
|
+
"""
|
|
121
|
+
global _PATCHED_FACTORY_CLS
|
|
122
|
+
|
|
123
|
+
if "GPIOZERO_PIN_FACTORY" in os.environ:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
# Imported lazily so the core install needs no gpiozero package.
|
|
128
|
+
from gpiozero import Device # noqa: PLC0415
|
|
129
|
+
from gpiozero.pins.rpigpio import RPiGPIOFactory # noqa: PLC0415
|
|
130
|
+
except Exception as exc: # gpiozero or its pin backend is unavailable
|
|
131
|
+
_LOGGER.debug("gpiozero dual-CS patch skipped: %s", exc)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
current_factory = Device.pin_factory
|
|
135
|
+
if current_factory is None:
|
|
136
|
+
try:
|
|
137
|
+
default_factory = Device._default_pin_factory()
|
|
138
|
+
except Exception: # any backend failure means no patch
|
|
139
|
+
_LOGGER.debug(
|
|
140
|
+
"gpiozero could not choose a default pin factory", exc_info=True
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
base_factory_cls = type(default_factory)
|
|
144
|
+
if base_factory_cls is not RPiGPIOFactory:
|
|
145
|
+
_LOGGER.debug(
|
|
146
|
+
"gpiozero default factory %s is not supported; not patching",
|
|
147
|
+
base_factory_cls.__name__,
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
else:
|
|
151
|
+
base_factory_cls = type(current_factory)
|
|
152
|
+
if base_factory_cls is _PATCHED_FACTORY_CLS:
|
|
153
|
+
return
|
|
154
|
+
# Any factory the application selected is a real choice, so keep it.
|
|
155
|
+
if base_factory_cls is not RPiGPIOFactory:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if _PATCHED_FACTORY_CLS is None or not issubclass(
|
|
159
|
+
_PATCHED_FACTORY_CLS, base_factory_cls
|
|
160
|
+
):
|
|
161
|
+
_PATCHED_FACTORY_CLS = _fixed_factory_for(base_factory_cls)
|
|
162
|
+
Device.pin_factory = _PATCHED_FACTORY_CLS()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""SPI transport backed by gpiozero's hardware SPI device."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from gpiozero import SPIDevice
|
|
7
|
+
from gpiozero.exc import DeviceClosed
|
|
8
|
+
|
|
9
|
+
from .base import TransportClosed, TransportError
|
|
10
|
+
from .gpiozero_helpers import fix_gpiozero_dual_cs_spi
|
|
11
|
+
from .selection import GPIOZERO_SPI_ARGS
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _GpiozeroSpiHandle(SPIDevice):
|
|
15
|
+
"""Private SPIDevice used only to obtain a shared gpiozero SPI object."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, **spi_args) -> None:
|
|
18
|
+
super().__init__(shared=True, **spi_args)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def raw(self) -> Any:
|
|
22
|
+
"""Return the gpiozero SPI object held by the device."""
|
|
23
|
+
return self._spi
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def value(self):
|
|
27
|
+
"""A raw SPI handle exposes no digital value."""
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GpiozeroSpiTransport:
|
|
32
|
+
"""SPI transport over a gpiozero SPI interface.
|
|
33
|
+
|
|
34
|
+
With no `spi` argument the transport builds a private gpiozero
|
|
35
|
+
`SPIDevice` from the same keyword arguments the legacy `MCP4xxx`
|
|
36
|
+
constructor accepted (`port`, `device`, `clock_pin`, `mosi_pin`,
|
|
37
|
+
`miso_pin`, `select_pin`). `shared=True` is kept so that several
|
|
38
|
+
devices can share one hardware SPI port.
|
|
39
|
+
|
|
40
|
+
Ownership follows construction: a transport that built its own handle
|
|
41
|
+
closes it. A transport wrapping a caller-supplied `spi=` never closes it
|
|
42
|
+
and does not join gpiozero's shared reference count, so the supplied
|
|
43
|
+
handle's owner must outlive the wrapper. Once the owner closes the handle,
|
|
44
|
+
this transport's transfers raise `TransportClosed`.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_SPI_ARGS = GPIOZERO_SPI_ARGS
|
|
48
|
+
|
|
49
|
+
def __init__(self, spi: Any = None, **spi_args) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Wrap an existing gpiozero SPI object or build a shared one.
|
|
52
|
+
|
|
53
|
+
:param spi: Existing gpiozero SPI object to wrap. The caller keeps
|
|
54
|
+
ownership and must keep it open for the lifetime of this transport;
|
|
55
|
+
when None a private `SPIDevice` is built from `spi_args` and owned
|
|
56
|
+
by this transport.
|
|
57
|
+
:param spi_args: gpiozero `SPIDevice` keywords (`port`, `device`,
|
|
58
|
+
`clock_pin`, `mosi_pin`, `miso_pin`, `select_pin`).
|
|
59
|
+
"""
|
|
60
|
+
self._closed = False
|
|
61
|
+
self._owns_raw = spi is None
|
|
62
|
+
self._handle = None
|
|
63
|
+
if spi is None:
|
|
64
|
+
fix_gpiozero_dual_cs_spi()
|
|
65
|
+
self._handle = _GpiozeroSpiHandle(**spi_args)
|
|
66
|
+
self._raw = self._handle.raw
|
|
67
|
+
else:
|
|
68
|
+
self._raw = spi
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def raw(self) -> Any:
|
|
72
|
+
"""Return the underlying gpiozero SPI object."""
|
|
73
|
+
return self._raw
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def closed(self) -> bool:
|
|
77
|
+
"""Return True once close() has completed successfully."""
|
|
78
|
+
return self._closed
|
|
79
|
+
|
|
80
|
+
def transfer(self, tx: Sequence[int]) -> list[int]:
|
|
81
|
+
"""Transfer one frame and validate the response length.
|
|
82
|
+
|
|
83
|
+
:param tx: Request frame.
|
|
84
|
+
:return: Response frame of the same length.
|
|
85
|
+
:raises TransportClosed: If this transport is closed or the owner of a
|
|
86
|
+
caller-supplied SPI handle has closed it.
|
|
87
|
+
:raises TransportError: If gpiozero returns the wrong number of words.
|
|
88
|
+
"""
|
|
89
|
+
if self._closed:
|
|
90
|
+
raise TransportClosed("GpiozeroSpiTransport is closed")
|
|
91
|
+
raw_closed = getattr(self._raw, "closed", False)
|
|
92
|
+
if isinstance(raw_closed, bool) and raw_closed:
|
|
93
|
+
raise TransportClosed(
|
|
94
|
+
"The underlying gpiozero SPI handle is closed; its owner must "
|
|
95
|
+
"outlive this wrapper"
|
|
96
|
+
)
|
|
97
|
+
frame = list(tx)
|
|
98
|
+
try:
|
|
99
|
+
rx = list(self._raw.transfer(frame))
|
|
100
|
+
except DeviceClosed as exc:
|
|
101
|
+
raise TransportClosed(
|
|
102
|
+
"The underlying gpiozero SPI handle was closed by its owner"
|
|
103
|
+
) from exc
|
|
104
|
+
if len(rx) != len(frame):
|
|
105
|
+
raise TransportError(
|
|
106
|
+
f"gpiozero returned {len(rx)} words for a {len(frame)}-word request"
|
|
107
|
+
)
|
|
108
|
+
return rx
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
"""Close an owned SPI handle.
|
|
112
|
+
|
|
113
|
+
A wrapper around a caller-supplied handle only marks itself closed and
|
|
114
|
+
leaves that handle untouched; its owner must close it. Repeated calls
|
|
115
|
+
after a successful close have no effect; a failed close of an owned
|
|
116
|
+
handle leaves the transport open and retryable.
|
|
117
|
+
"""
|
|
118
|
+
if self._closed:
|
|
119
|
+
return
|
|
120
|
+
if self._owns_raw and self._handle is not None:
|
|
121
|
+
self._handle.close()
|
|
122
|
+
self._closed = True
|
scietex/hal/spi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Default SPI backend selection shared by the device constructors."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .base import SpiTransport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
#: Keyword names accepted by both the legacy constructor and the gpiozero
|
|
10
|
+
#: backend, in the order the transport forwards them.
|
|
11
|
+
GPIOZERO_SPI_ARGS: tuple[str, ...] = (
|
|
12
|
+
"port",
|
|
13
|
+
"device",
|
|
14
|
+
"clock_pin",
|
|
15
|
+
"mosi_pin",
|
|
16
|
+
"miso_pin",
|
|
17
|
+
"select_pin",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
#: Pin keywords that select the gpiozero backend; `port` and `device` alone
|
|
21
|
+
#: are also valid for the spidev backend.
|
|
22
|
+
GPIOZERO_PIN_ARGS: tuple[str, ...] = (
|
|
23
|
+
"clock_pin",
|
|
24
|
+
"mosi_pin",
|
|
25
|
+
"miso_pin",
|
|
26
|
+
"select_pin",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_default_transport( # noqa: PLR0913
|
|
31
|
+
*,
|
|
32
|
+
port: int | None = None,
|
|
33
|
+
device: int | None = None,
|
|
34
|
+
clock_pin: int | None = None,
|
|
35
|
+
mosi_pin: int | None = None,
|
|
36
|
+
miso_pin: int | None = None,
|
|
37
|
+
select_pin: int | None = None,
|
|
38
|
+
) -> "SpiTransport":
|
|
39
|
+
"""
|
|
40
|
+
Build the default SPI transport from the legacy keyword arguments.
|
|
41
|
+
|
|
42
|
+
Pin keywords select the gpiozero backend and require the optional
|
|
43
|
+
``gpiozero`` package. Without pin keywords the spidev backend is used;
|
|
44
|
+
its ``spidev`` package is imported lazily when the transport is built.
|
|
45
|
+
|
|
46
|
+
:param port: SPI port (gpiozero) or bus number (spidev).
|
|
47
|
+
:param device: SPI device number (chip select).
|
|
48
|
+
:param clock_pin: GPIO pin used as SPI clock.
|
|
49
|
+
:param mosi_pin: GPIO pin used as SPI MOSI.
|
|
50
|
+
:param miso_pin: GPIO pin used as SPI MISO.
|
|
51
|
+
:param select_pin: GPIO pin used as SPI chip select.
|
|
52
|
+
:return: SPI transport selected by the supplied keywords.
|
|
53
|
+
:raises ImportError: If pin keywords are given and gpiozero is missing.
|
|
54
|
+
"""
|
|
55
|
+
supplied = {
|
|
56
|
+
"port": port,
|
|
57
|
+
"device": device,
|
|
58
|
+
"clock_pin": clock_pin,
|
|
59
|
+
"mosi_pin": mosi_pin,
|
|
60
|
+
"miso_pin": miso_pin,
|
|
61
|
+
"select_pin": select_pin,
|
|
62
|
+
}
|
|
63
|
+
if any(supplied[name] is not None for name in GPIOZERO_PIN_ARGS):
|
|
64
|
+
try:
|
|
65
|
+
# Imported lazily so the core install needs no gpiozero package.
|
|
66
|
+
from .gpiozero_spi import GpiozeroSpiTransport # noqa: PLC0415
|
|
67
|
+
except ImportError as exc:
|
|
68
|
+
raise ImportError(
|
|
69
|
+
"The gpiozero pin keywords require the optional 'gpiozero' "
|
|
70
|
+
"package. Install it with: pip install "
|
|
71
|
+
"'scietex.hal.spi[gpiozero]'"
|
|
72
|
+
) from exc
|
|
73
|
+
spi_args = {
|
|
74
|
+
name: supplied[name]
|
|
75
|
+
for name in GPIOZERO_SPI_ARGS
|
|
76
|
+
if supplied[name] is not None
|
|
77
|
+
}
|
|
78
|
+
return GpiozeroSpiTransport(**spi_args)
|
|
79
|
+
# Imported lazily so the core install needs no spidev package.
|
|
80
|
+
from .spidev_spi import SpiDevTransport # noqa: PLC0415
|
|
81
|
+
|
|
82
|
+
return SpiDevTransport(bus=port or 0, device=device or 0)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""SPI transport backed by the Linux spidev device interface."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import TransportClosed, TransportError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_spidev():
|
|
11
|
+
"""Import and return spidev.SpiDev, raising an actionable error if missing."""
|
|
12
|
+
try:
|
|
13
|
+
# Imported lazily so the core install needs no spidev package.
|
|
14
|
+
from spidev import SpiDev # noqa: PLC0415 - kept lazy so spidev stays optional
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"SpiDevTransport requires the optional 'spidev' package. "
|
|
18
|
+
"Install it with: pip install 'scietex.hal.spi[spi]'"
|
|
19
|
+
) from exc
|
|
20
|
+
return SpiDev
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SpiDevTransport:
|
|
24
|
+
"""SPI transport over /dev/spidev<bus>.<device>.
|
|
25
|
+
|
|
26
|
+
Linux only. Requires kernel SPI (`dtparam=spi=on` in
|
|
27
|
+
/boot/firmware/config.txt, or the older /boot/config.txt), the device
|
|
28
|
+
node to exist, and read access for the current user (member of the
|
|
29
|
+
`gpio` group, or root).
|
|
30
|
+
|
|
31
|
+
Transfers use `xfer2`, which keeps CS asserted for the whole frame;
|
|
32
|
+
`xfer` would deassert CS between 8-bit words and break the 16-bit
|
|
33
|
+
MCP4xxx command+data words.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# One argument per spidev setting; the tuning surface is deliberately explicit.
|
|
37
|
+
def __init__( # noqa: PLR0913, PLR0917
|
|
38
|
+
self,
|
|
39
|
+
bus: int = 0,
|
|
40
|
+
device: int = 0,
|
|
41
|
+
max_speed_hz: int = 500_000,
|
|
42
|
+
mode: int = 0,
|
|
43
|
+
bits_per_word: int = 8,
|
|
44
|
+
lsbfirst: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Open and configure a spidev device.
|
|
48
|
+
|
|
49
|
+
The settings are applied as ioctls on open. If any of them is
|
|
50
|
+
rejected, the handle is closed before `TransportError` is raised with
|
|
51
|
+
the original error as its cause.
|
|
52
|
+
|
|
53
|
+
:param bus: SPI bus number (`/dev/spidev<bus>.<device>`).
|
|
54
|
+
:param device: Chip-select number on the bus.
|
|
55
|
+
:param max_speed_hz: Clock frequency in Hz.
|
|
56
|
+
:param mode: SPI mode (clock polarity and phase).
|
|
57
|
+
:param bits_per_word: Word size in bits.
|
|
58
|
+
:param lsbfirst: Send the least-significant bit first when True.
|
|
59
|
+
:raises TransportError: If the device cannot be opened or configured.
|
|
60
|
+
:raises ImportError: If the optional `spidev` package is missing.
|
|
61
|
+
"""
|
|
62
|
+
spidev = _load_spidev()
|
|
63
|
+
self._spi = spidev()
|
|
64
|
+
self._closed = False
|
|
65
|
+
try:
|
|
66
|
+
self._spi.open(bus, device)
|
|
67
|
+
self._spi.max_speed_hz = max_speed_hz
|
|
68
|
+
self._spi.mode = mode
|
|
69
|
+
self._spi.bits_per_word = bits_per_word
|
|
70
|
+
self._spi.lsbfirst = lsbfirst
|
|
71
|
+
self._spi.cshigh = False
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
with contextlib.suppress(OSError):
|
|
74
|
+
# The original open/configuration error is the actionable one.
|
|
75
|
+
self._spi.close()
|
|
76
|
+
raise TransportError(
|
|
77
|
+
f"Failed to open or configure /dev/spidev{bus}.{device}: {exc}. "
|
|
78
|
+
"Ensure SPI is enabled (dtparam=spi=on) and that the current "
|
|
79
|
+
"user is in the 'gpio' group; the provided udev rule grants "
|
|
80
|
+
"the device to that group."
|
|
81
|
+
) from exc
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def raw(self) -> Any:
|
|
85
|
+
"""Return the underlying spidev.SpiDev object."""
|
|
86
|
+
return self._spi
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def closed(self) -> bool:
|
|
90
|
+
"""Return True once close() has completed successfully."""
|
|
91
|
+
return self._closed
|
|
92
|
+
|
|
93
|
+
def transfer(self, tx: Sequence[int]) -> list[int]:
|
|
94
|
+
"""Transfer one frame with CS held asserted for its full length."""
|
|
95
|
+
if self._closed:
|
|
96
|
+
raise TransportClosed("SpiDevTransport is closed")
|
|
97
|
+
frame = list(tx)
|
|
98
|
+
rx = list(self._spi.xfer2(frame))
|
|
99
|
+
if len(rx) != len(frame):
|
|
100
|
+
raise TransportError(
|
|
101
|
+
f"spidev returned {len(rx)} words for a {len(frame)}-word request"
|
|
102
|
+
)
|
|
103
|
+
return rx
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Close the device handle.
|
|
107
|
+
|
|
108
|
+
Repeated calls after a successful close have no effect; a failed close
|
|
109
|
+
leaves the transport open and retryable.
|
|
110
|
+
"""
|
|
111
|
+
if self._closed:
|
|
112
|
+
return
|
|
113
|
+
self._spi.close()
|
|
114
|
+
self._closed = True
|
|
@@ -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,14 @@
|
|
|
1
|
+
scietex/hal/spi/__init__.py,sha256=2xw56csGDt3Sn0r4Ef2_3y_Dol01RNGTIZ9WNLJgJL8,1828
|
|
2
|
+
scietex/hal/spi/base.py,sha256=VWouwaOzr_JrNGv3iqC5iwMb10KzAxN_qVa0tc6Fh3U,2048
|
|
3
|
+
scietex/hal/spi/fake.py,sha256=kXhtW1DTn41s0rDg4joyXCinh5_6T5xPa1HXti-MGBU,3731
|
|
4
|
+
scietex/hal/spi/gpiozero_helpers.py,sha256=zK8_qzg-Rlw44LCvKj6Tg7TqnfAX3BkwJ_Y6epriqZg,6191
|
|
5
|
+
scietex/hal/spi/gpiozero_spi.py,sha256=cZ3W-TtuDlRzrT_pBORQJ5-qZ3g0GtAES5FDyR6eCfs,4514
|
|
6
|
+
scietex/hal/spi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
scietex/hal/spi/selection.py,sha256=7PuZzq-HutjqUfQ9Ch8f6q-f-KwEvrmO4ZZAjGpODlM,2773
|
|
8
|
+
scietex/hal/spi/spidev_spi.py,sha256=WL_mTk7tbPGG-ew33mLfF1RYzk5PSS6pw7_12x51bS8,4213
|
|
9
|
+
scietex/hal/spi/version.py,sha256=-6LRwSS3coyMIQkeofZjGTMXkVG7dm2gwquyq8O_vF0,154
|
|
10
|
+
scietex_hal_spi-0.1.0.dist-info/licenses/LICENSE,sha256=Kv8K8tNh4XWlpCNncypg7EhUWm4drfJp0zCGNsrp6So,1072
|
|
11
|
+
scietex_hal_spi-0.1.0.dist-info/METADATA,sha256=2pN8lUA03ypAw17Fvkdzpn2c8EtTKvyJxqvo-lZmOpU,7800
|
|
12
|
+
scietex_hal_spi-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
scietex_hal_spi-0.1.0.dist-info/top_level.txt,sha256=AyNTe2f94XrlwxyUGMuitoEj6mqEacwNkt8nU47GjvI,8
|
|
14
|
+
scietex_hal_spi-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
scietex
|