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.
Files changed (28) hide show
  1. scietex_hal_spi-0.1.0/LICENSE +21 -0
  2. scietex_hal_spi-0.1.0/PKG-INFO +175 -0
  3. scietex_hal_spi-0.1.0/README.md +122 -0
  4. scietex_hal_spi-0.1.0/pyproject.toml +69 -0
  5. scietex_hal_spi-0.1.0/setup.cfg +4 -0
  6. scietex_hal_spi-0.1.0/src/scietex/hal/spi/__init__.py +61 -0
  7. scietex_hal_spi-0.1.0/src/scietex/hal/spi/base.py +72 -0
  8. scietex_hal_spi-0.1.0/src/scietex/hal/spi/fake.py +96 -0
  9. scietex_hal_spi-0.1.0/src/scietex/hal/spi/gpiozero_helpers.py +162 -0
  10. scietex_hal_spi-0.1.0/src/scietex/hal/spi/gpiozero_spi.py +122 -0
  11. scietex_hal_spi-0.1.0/src/scietex/hal/spi/py.typed +0 -0
  12. scietex_hal_spi-0.1.0/src/scietex/hal/spi/selection.py +82 -0
  13. scietex_hal_spi-0.1.0/src/scietex/hal/spi/spidev_spi.py +114 -0
  14. scietex_hal_spi-0.1.0/src/scietex/hal/spi/version.py +8 -0
  15. scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/PKG-INFO +175 -0
  16. scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/SOURCES.txt +26 -0
  17. scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/dependency_links.txt +1 -0
  18. scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/requires.txt +19 -0
  19. scietex_hal_spi-0.1.0/src/scietex.hal.spi.egg-info/top_level.txt +1 -0
  20. scietex_hal_spi-0.1.0/tests/test_base.py +115 -0
  21. scietex_hal_spi-0.1.0/tests/test_fake.py +187 -0
  22. scietex_hal_spi-0.1.0/tests/test_gpiozero.py +192 -0
  23. scietex_hal_spi-0.1.0/tests/test_gpiozero_helpers.py +171 -0
  24. scietex_hal_spi-0.1.0/tests/test_optional_backends.py +124 -0
  25. scietex_hal_spi-0.1.0/tests/test_package.py +56 -0
  26. scietex_hal_spi-0.1.0/tests/test_selection.py +136 -0
  27. scietex_hal_spi-0.1.0/tests/test_spidev.py +244 -0
  28. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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