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.
@@ -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
@@ -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
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,8 @@
1
+ """
2
+ Module containing the package version information.
3
+
4
+ Attributes:
5
+ __version__ (str): The current version of the package.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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