pap-spinel 0.0.2__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.
@@ -0,0 +1,30 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build-and-publish:
9
+ runs-on: ubuntu-latest
10
+
11
+ permissions:
12
+ id-token: write
13
+ contents: read
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.11"
22
+
23
+ - name: Install build tool
24
+ run: python -m pip install --upgrade pip build
25
+
26
+ - name: Build package
27
+ run: python -m build
28
+
29
+ - name: Publish package to PyPI
30
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Levad
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,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: pap_spinel
3
+ Version: 0.0.2
4
+ Summary: Spinel protocol over UART/TCP for Papouch MCUs.
5
+ Project-URL: Source, https://github.com/VladislavLevitskii/pap_spinel
6
+ Author-email: VladislavLevitskii <vladlevitskii@gmail.com>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: pyserial-asyncio>=0.6
11
+ Requires-Dist: pyserial>=3.5
@@ -0,0 +1,198 @@
1
+ # pap_spinel
2
+
3
+ An asynchronous Python library for the Papouch Spinel binary protocol (Format 97) over UART or TCP. It can construct and verify individual frames, incrementally parse an incoming data stream, and match responses to asynchronous requests using `SIG`.
4
+
5
+ The library does not provide application-level instruction semantics or data structures. These are determined by the target device's firmware. However, the transport layer, Spinel framing, and standard response processing are common across devices.
6
+
7
+ Requires Python 3.12 or newer.
8
+
9
+ ## Frame Format 97
10
+
11
+ Each frame has the following binary structure:
12
+
13
+ ```text
14
+ PRE FRM NUM (uint16 big-endian) ADR SIG INST DATA... SUM CR
15
+ 2A 61 .. .. .. .. .. 0D
16
+
17
+ ```
18
+
19
+ `NUM` equals `5 + length of DATA` and encompasses `ADR`, `SIG`, `INST`, `DATA`, `SUM`, and `CR` (it excludes the first four bytes). `SUM` is automatically calculated by `Packet97` so that the sum of all bytes from `PRE` to `SUM` equals `0xFF` modulo 256.
20
+
21
+ Important constants exported by the package:
22
+
23
+ | Constant | Value | Meaning |
24
+ | --- | --- | --- |
25
+ | `PRE` | `0x2A` | Start of frame |
26
+ | `FRM` | `0x61` | Spinel format 97 |
27
+ | `CR` | `0x0D` | End of frame |
28
+ | `ADR_BROADCAST` | `0xFE` | Broadcast address |
29
+ | `INST_INFO` | `0xF3` | INFO request instruction |
30
+
31
+ The client chooses a `SIG` (signature) for each request. The response must carry the same `SIG` so that `SpinelClient` can route it to the correct call when handling multiple concurrent requests.
32
+
33
+ ## Constructing and Verifying a Single Frame
34
+
35
+ `Packet97` is a low-level representation of a single complete frame. During serialization, it automatically calculates `NUM` and `SUM`; during parsing, it verifies the length, header, terminator, and checksum.
36
+
37
+ ```python
38
+ from pap_spinel import Packet97
39
+
40
+ # INFO broadcast with a custom SIG.
41
+ request = Packet97(adr=0xFE, sig=0x42, inst=0xF3)
42
+ wire_bytes = request.to_bytes()
43
+ print(request.hex())
44
+ # 2A 61 00 05 FE 42 F3 .. 0D
45
+
46
+ # raw_reply must contain exactly one complete frame, without surrounding bytes.
47
+ raw_reply = b"\x2A\x61\x00\x05\x31\x42\xF3\x09\x0D"
48
+ reply = Packet97.from_bytes(raw_reply)
49
+
50
+ print(f"ADR=0x{reply.adr:02X}, SIG=0x{reply.sig:02X}, INST=0x{reply.inst:02X}")
51
+ print(f"DATA={reply.data.hex(' ').upper()}")
52
+
53
+ ```
54
+
55
+ For a malformed frame, `Packet97.from_bytes()` raises a `SpinelProtocolError`. Use this method only when you already know the boundaries of the complete frame, such as when reading from a stored binary file.
56
+
57
+ ## Parsing a UART/TCP Stream
58
+
59
+ Both serial and TCP reads can return arbitrarily fragmented data chunks: a single frame might arrive in multiple blocks, and a single block might contain multiple frames or noise preceding the header. For these scenarios, use `Packet97StreamParser`.
60
+
61
+ ```python
62
+ from pap_spinel import Packet97StreamParser
63
+
64
+ parser = Packet97StreamParser()
65
+
66
+ for received_chunk in chunks_from_your_reader:
67
+ for packet, errors in parser.feed(received_chunk):
68
+ # By default, only valid frames are yielded.
69
+ assert not errors
70
+ print(packet.hex())
71
+
72
+ ```
73
+
74
+ To diagnose corrupted communication, create the parser with `include_invalid=True`. It will then also yield frames with a valid length but an invalid checksum, along with the error (e.g., `['SUM']`).
75
+
76
+ ```python
77
+ parser = Packet97StreamParser(include_invalid=True)
78
+ for packet, errors in parser.feed(received_chunk):
79
+ if errors:
80
+ print(f"Invalid frame ({', '.join(errors)}): {packet.hex()}")
81
+ else:
82
+ print(f"Valid frame: {packet.hex()}")
83
+
84
+ ```
85
+
86
+ The `dropped_bytes` and `bad_packets` properties indicate the number of bytes discarded during resynchronization and the number of invalid complete frames, respectively.
87
+
88
+ ## INFO over Serial Port
89
+
90
+ `SpinelClient` runs a background reading task, continuously parses incoming data, and waits for a response with a matching `SIG`. Instantiate it using an asynchronous context manager. The following example sends an INFO request to a specific device at address `0x31` on port `COM5`.
91
+
92
+ ```python
93
+ import asyncio
94
+
95
+ from pap_spinel import Packet97, SerialTransport, SpinelClient
96
+
97
+
98
+ def describe_info(packet: Packet97) -> None:
99
+ print(f"Device: ADR=0x{packet.adr:02X}, INST=0x{packet.inst:02X}")
100
+ print(f"INFO hex: {packet.data.hex(' ').upper() or '(empty)'}")
101
+
102
+ # ASCII is for diagnostic preview only. DATA format is defined by firmware.
103
+ text = packet.data.decode("ascii", errors="replace")
104
+ if text:
105
+ print(f"INFO ASCII: {text!r}")
106
+
107
+
108
+ async def main() -> None:
109
+ transport = SerialTransport("COM5", baudrate=115_200)
110
+ async with SpinelClient(transport, default_timeout=2.0) as client:
111
+ info = await client.info(addr=0x31)
112
+ describe_info(info)
113
+
114
+
115
+ asyncio.run(main())
116
+
117
+ ```
118
+
119
+ For a broadcast, use `ADR_BROADCAST` (`0xFE`) instead of `0x31`, or omit the `addr` parameter entirely. A broadcast may trigger responses from multiple devices; the `info()` method will return the first response with a matching `SIG`. For deterministic results, use the address of a specific device.
120
+
121
+ ## INFO over TCP
122
+
123
+ For Spinel tunneled over TCP (e.g., via Lantronix XPort), the code is identical; only the transport method changes.
124
+
125
+ ```python
126
+ import asyncio
127
+
128
+ from pap_spinel import TcpTransport, SpinelClient
129
+
130
+
131
+ async def main() -> None:
132
+ async with SpinelClient(TcpTransport("192.0.2.10", 10001)) as client:
133
+ info = await client.info(addr=0x31, timeout=3.0)
134
+ print(info.hex())
135
+ print(info.data.hex(' ').upper())
136
+
137
+
138
+ asyncio.run(main())
139
+
140
+ ```
141
+
142
+ `192.0.2.10` is a documentation placeholder address; replace it with the actual IP address and TCP port of your tunnel. You can configure the connection timeout by setting `connect_timeout`, for example: `TcpTransport(host, port, connect_timeout=10.0)`.
143
+
144
+ ## Custom Instructions and ACKs
145
+
146
+ To send data for a firmware-defined instruction, you can use `request()` or `send_and_ack()`. The latter expects an ACK frame: a frame where `INST <= 0x0F` and the first byte of `DATA` contains the ACK code. By default, only the `ACK_OK` (`0x00`) code is considered successful.
147
+
148
+ ```python
149
+ import asyncio
150
+
151
+ from pap_spinel import SerialTransport, SpinelClient, SpinelNakError
152
+
153
+
154
+ async def main() -> None:
155
+ async with SpinelClient(SerialTransport("COM5", 115_200)) as client:
156
+ try:
157
+ await client.send_and_ack(
158
+ addr=0x31,
159
+ inst=0xE0, # Replace with an instruction from your device's documentation.
160
+ data=b"\x01\x02",
161
+ )
162
+ except SpinelNakError as error:
163
+ print(f"Device rejected the request: 0x{error.code:02X} ({error.name})")
164
+
165
+
166
+ asyncio.run(main())
167
+
168
+ ```
169
+
170
+ The `request()` method returns the first response with a matching `SIG`, without assuming it's an ACK frame. Use it for instructions that return a data response. `send_raw()` transmits a pre-constructed `Packet97` and does not wait for a response.
171
+
172
+ ## Unsolicited Frames and Errors
173
+
174
+ A frame that does not belong to any pending request can be handled via the `on_unsolicited` callback. The callback can be a standard or an asynchronous function.
175
+
176
+ ```python
177
+ from pap_spinel import Packet97, SerialTransport, SpinelClient
178
+
179
+
180
+ def on_unsolicited(packet: Packet97) -> None:
181
+ print(f"Event from 0x{packet.adr:02X}: INST=0x{packet.inst:02X}")
182
+
183
+
184
+ client = SpinelClient(
185
+ SerialTransport("COM5", 115_200),
186
+ on_unsolicited=on_unsolicited,
187
+ )
188
+
189
+ ```
190
+
191
+ When working with the client, you should handle the following exceptions:
192
+
193
+ | Exception | Situation |
194
+ | --- | --- |
195
+ | `SpinelProtocolError` | Invalid individual frame in `Packet97.from_bytes()` |
196
+ | `SpinelTimeoutError` | Device did not respond within the specified timeout |
197
+ | `SpinelNakError` | `send_and_ack()` received an unpermitted ACK code |
198
+ | `SpinelTransportError` | Failed to open or use the UART/TCP transport |
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pap_spinel"
7
+ version = "0.0.2"
8
+ description = "Spinel protocol over UART/TCP for Papouch MCUs."
9
+ requires-python = ">=3.12"
10
+ license = { text = "MIT" }
11
+ authors = [
12
+ { name = "VladislavLevitskii", email = "vladlevitskii@gmail.com" }
13
+ ]
14
+ dependencies = [
15
+ "pyserial>=3.5",
16
+ "pyserial-asyncio>=0.6",
17
+ ]
18
+
19
+ [project.urls]
20
+ Source = "https://github.com/VladislavLevitskii/pap_spinel"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/pap_spinel"]
@@ -0,0 +1,61 @@
1
+ """Spinel protocol (Papouch) over UART/TCP."""
2
+
3
+ from .ack import (
4
+ ACK_DATA_ERROR,
5
+ ACK_FAILURE,
6
+ ACK_GENERAL_ERROR,
7
+ ACK_NAMES,
8
+ ACK_NO_DATA,
9
+ ACK_NOT_PERMITTED,
10
+ ACK_OK,
11
+ ACK_UNKNOWN_INSTRUCTION,
12
+ ack_name,
13
+ )
14
+ from .client import SpinelClient
15
+ from .errors import (
16
+ SpinelError,
17
+ SpinelNakError,
18
+ SpinelProtocolError,
19
+ SpinelTimeoutError,
20
+ SpinelTransportError,
21
+ )
22
+ from .packet import (
23
+ ADR_BROADCAST,
24
+ CR,
25
+ FRM,
26
+ INST_INFO,
27
+ PRE,
28
+ Packet97,
29
+ Packet97StreamParser,
30
+ )
31
+ from .transport import SerialTransport, SpinelTransport, TcpTransport
32
+
33
+ __version__ = "0.0.2"
34
+
35
+ __all__ = [
36
+ "ACK_DATA_ERROR",
37
+ "ACK_FAILURE",
38
+ "ACK_GENERAL_ERROR",
39
+ "ACK_NAMES",
40
+ "ACK_NOT_PERMITTED",
41
+ "ACK_NO_DATA",
42
+ "ACK_OK",
43
+ "ACK_UNKNOWN_INSTRUCTION",
44
+ "ADR_BROADCAST",
45
+ "CR",
46
+ "FRM",
47
+ "INST_INFO",
48
+ "PRE",
49
+ "Packet97",
50
+ "Packet97StreamParser",
51
+ "SerialTransport",
52
+ "SpinelClient",
53
+ "SpinelError",
54
+ "SpinelNakError",
55
+ "SpinelProtocolError",
56
+ "SpinelTimeoutError",
57
+ "SpinelTransport",
58
+ "SpinelTransportError",
59
+ "TcpTransport",
60
+ "ack_name",
61
+ ]
@@ -0,0 +1,25 @@
1
+ """Spinel ACK code table (response when INST <= 0x0F)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ ACK_OK = 0x00
6
+ ACK_GENERAL_ERROR = 0x01
7
+ ACK_UNKNOWN_INSTRUCTION = 0x02
8
+ ACK_DATA_ERROR = 0x03
9
+ ACK_NOT_PERMITTED = 0x04
10
+ ACK_FAILURE = 0x05
11
+ ACK_NO_DATA = 0x06
12
+
13
+ ACK_NAMES: dict[int, str] = {
14
+ ACK_OK: "Ok",
15
+ ACK_GENERAL_ERROR: "General error",
16
+ ACK_UNKNOWN_INSTRUCTION: "Unknown instruction code",
17
+ ACK_DATA_ERROR: "Data error",
18
+ ACK_NOT_PERMITTED: "Not permitted",
19
+ ACK_FAILURE: "Failure (service needed)",
20
+ ACK_NO_DATA: "No data available",
21
+ }
22
+
23
+
24
+ def ack_name(code: int) -> str:
25
+ return ACK_NAMES.get(code, f"Unknown ACK 0x{code:02X}")
@@ -0,0 +1,171 @@
1
+ """Asyncio Spinel client: request/response with SIG matching + unsolicited packet handler.
2
+
3
+ Usage:
4
+ async with SpinelClient(SerialTransport("COM5", 115200)) as c:
5
+ info = await c.info(addr=0xFE)
6
+ await c.send_and_ack(addr=0x31, inst=0xE0, data=b"\\x01")
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Self
15
+
16
+ from .ack import ACK_OK, ack_name
17
+ from .errors import SpinelNakError, SpinelTimeoutError, SpinelTransportError
18
+ from .packet import INST_INFO, Packet97, Packet97StreamParser
19
+ from .transport import SpinelTransport
20
+
21
+ logger = logging.getLogger("pap_spinel")
22
+
23
+ OnPacket = Callable[[Packet97], Awaitable[None] | None]
24
+
25
+
26
+ class SpinelClient:
27
+ """Async Spinel client. Owns a reader task that demultiplexes responses by SIG."""
28
+
29
+ def __init__(
30
+ self,
31
+ transport: SpinelTransport,
32
+ *,
33
+ default_timeout: float = 2.0,
34
+ on_unsolicited: OnPacket | None = None,
35
+ include_invalid: bool = False,
36
+ ) -> None:
37
+ self.transport = transport
38
+ self.default_timeout = default_timeout
39
+ self.on_unsolicited = on_unsolicited
40
+ self._parser = Packet97StreamParser(include_invalid=include_invalid)
41
+ self._sig_counter = 0x37 # mirrors JS init (55)
42
+ self._pending: dict[int, asyncio.Future[Packet97]] = {}
43
+ self._reader_task: asyncio.Task[None] | None = None
44
+ self._closed = False
45
+
46
+ # ---- lifecycle ------------------------------------------------------
47
+
48
+ async def open(self) -> None:
49
+ await self.transport.open()
50
+ self._reader_task = asyncio.create_task(self._reader_loop(), name="spinel-rx")
51
+
52
+ async def close(self) -> None:
53
+ self._closed = True
54
+ if self._reader_task is not None:
55
+ self._reader_task.cancel()
56
+ try:
57
+ await self._reader_task
58
+ except asyncio.CancelledError:
59
+ pass
60
+ finally:
61
+ self._reader_task = None
62
+ await self.transport.close()
63
+ # Wake up any pending futures
64
+ for fut in self._pending.values():
65
+ if not fut.done():
66
+ fut.set_exception(SpinelTransportError("Client closed"))
67
+ self._pending.clear()
68
+
69
+ async def __aenter__(self) -> Self:
70
+ await self.open()
71
+ return self
72
+
73
+ async def __aexit__(self, *exc: object) -> None:
74
+ await self.close()
75
+
76
+ # ---- SIG management -------------------------------------------------
77
+
78
+ def _next_sig(self) -> int:
79
+ sig = self._sig_counter & 0xFF
80
+ self._sig_counter = (self._sig_counter + 1) & 0xFF
81
+ return sig
82
+
83
+ # ---- send -----------------------------------------------------------
84
+
85
+ async def send_raw(self, packet: Packet97) -> None:
86
+ """Send a packet without waiting for any response."""
87
+ if self._closed:
88
+ raise SpinelTransportError("Client closed")
89
+ logger.debug("TX: %s", packet.hex())
90
+ await self.transport.write(packet.to_bytes())
91
+
92
+ async def request(
93
+ self,
94
+ *,
95
+ addr: int,
96
+ inst: int,
97
+ data: bytes = b"",
98
+ sig: int | None = None,
99
+ timeout: float | None = None,
100
+ ) -> Packet97:
101
+ """Send packet and await first response matching its SIG. Raises SpinelTimeoutError."""
102
+ if sig is None:
103
+ sig = self._next_sig()
104
+ pkt = Packet97(adr=addr, sig=sig, inst=inst, data=data)
105
+ fut: asyncio.Future[Packet97] = asyncio.get_running_loop().create_future()
106
+ # Last-writer-wins on SIG collision — matches JS stack append behavior
107
+ self._pending[sig] = fut
108
+ try:
109
+ await self.send_raw(pkt)
110
+ return await asyncio.wait_for(fut, timeout or self.default_timeout)
111
+ except TimeoutError as exc:
112
+ raise SpinelTimeoutError(
113
+ f"No response for SIG=0x{sig:02X} INST=0x{inst:02X} ADR=0x{addr:02X}"
114
+ f" within {timeout or self.default_timeout}s"
115
+ ) from exc
116
+ finally:
117
+ self._pending.pop(sig, None)
118
+
119
+ async def send_and_ack(
120
+ self,
121
+ *,
122
+ addr: int,
123
+ inst: int,
124
+ data: bytes = b"",
125
+ sig: int | None = None,
126
+ timeout: float | None = None,
127
+ allowed_acks: tuple[int, ...] = (ACK_OK,),
128
+ ) -> Packet97:
129
+ """Send + require ACK with code in ``allowed_acks`` (default just 0x00 Ok). Raises SpinelNakError."""
130
+ resp = await self.request(addr=addr, inst=inst, data=data, sig=sig, timeout=timeout)
131
+ if not resp.is_ack():
132
+ return resp # Not an ACK frame, return as-is (caller decides)
133
+ code = resp.ack_code()
134
+ if code is None or code not in allowed_acks:
135
+ raise SpinelNakError(code or -1, ack_name(code or -1), packet=resp)
136
+ return resp
137
+
138
+ async def info(self, addr: int = 0xFE, *, timeout: float | None = None) -> Packet97:
139
+ """Send INFO (INST 0xF3) and wait for reply."""
140
+ return await self.request(addr=addr, inst=INST_INFO, timeout=timeout)
141
+
142
+ # ---- reader loop ----------------------------------------------------
143
+
144
+ async def _reader_loop(self) -> None:
145
+ try:
146
+ while not self._closed:
147
+ try:
148
+ chunk = await self.transport.read(4096)
149
+ except SpinelTransportError as exc:
150
+ logger.warning("Reader loop exiting: %s", exc)
151
+ break
152
+ if not chunk:
153
+ logger.debug("Reader loop exiting: EOF received")
154
+ break
155
+ for pkt, errors in self._parser.feed(chunk):
156
+ if errors:
157
+ logger.warning("invalid packet: %s %s", errors, pkt.hex())
158
+ fut = self._pending.get(pkt.sig)
159
+ if fut is not None and not fut.done():
160
+ fut.set_result(pkt)
161
+ continue
162
+ if self.on_unsolicited is not None:
163
+ result = self.on_unsolicited(pkt)
164
+ if asyncio.iscoroutine(result):
165
+ asyncio.create_task(result)
166
+ else:
167
+ logger.debug("RX unmatched SIG=0x%02X INST=0x%02X", pkt.sig, pkt.inst)
168
+ except asyncio.CancelledError:
169
+ raise
170
+ except Exception:
171
+ logger.exception("reader loop crashed")
@@ -0,0 +1,33 @@
1
+ """Exception hierarchy for pap_spinel."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SpinelError(Exception):
7
+ """Base for all Spinel errors."""
8
+
9
+
10
+ class SpinelProtocolError(SpinelError):
11
+ """Malformed packet (PRE/FRM/NUM/CR/SUM)."""
12
+
13
+ def __init__(self, message: str, errors: list[str] | None = None) -> None:
14
+ super().__init__(message)
15
+ self.errors = errors or []
16
+
17
+
18
+ class SpinelTimeoutError(SpinelError):
19
+ """No response within timeout."""
20
+
21
+
22
+ class SpinelNakError(SpinelError):
23
+ """Device replied with ACK != 0x00."""
24
+
25
+ def __init__(self, code: int, name: str, packet: object | None = None) -> None:
26
+ super().__init__(f"NAK 0x{code:02X} ({name})")
27
+ self.code = code
28
+ self.name = name
29
+ self.packet = packet
30
+
31
+
32
+ class SpinelTransportError(SpinelError):
33
+ """Underlying transport (serial/TCP) failure."""
@@ -0,0 +1,199 @@
1
+ """Spinel format 97 (binary, 0x61 = 'a') packet.
2
+
3
+ Wire layout:
4
+ PRE(0x2A) FRM(0x61) NUM(uint16 BE) ADR SIG INST DATA... SUM CR(0x0D)
5
+ where NUM = 5 + len(DATA) and SUM = 0xFF - (sum of all bytes from PRE through DATA) & 0xFF.
6
+
7
+ Ported from Dockbox4/backend/spinel.js (class PacketSpinel97).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ from .errors import SpinelProtocolError
15
+
16
+ PRE = 0x2A
17
+ FRM = 0x61 # 97 decimal — 'a'
18
+ CR = 0x0D
19
+ MIN_LEN = 9 # header(4) + ADR + SIG + INST + SUM + CR
20
+ ADR_BROADCAST = 0xFE
21
+ INST_INFO = 0xF3
22
+
23
+
24
+ @dataclass
25
+ class Packet97:
26
+ """Spinel format 97 packet."""
27
+
28
+ adr: int = ADR_BROADCAST
29
+ sig: int = 0x00
30
+ inst: int = INST_INFO
31
+ data: bytes = b""
32
+ # Optional override of decoded SUM (for diagnostics / round-tripping malformed packets)
33
+ sum_override: int | None = field(default=None, repr=False)
34
+
35
+ # ---- size + checksum ------------------------------------------------
36
+
37
+ @property
38
+ def num(self) -> int:
39
+ """NUM = 5 + len(DATA)."""
40
+ return 5 + len(self.data)
41
+
42
+ def compute_sum(self) -> int:
43
+ """SUM byte = 0xFF - (sum of PRE..last DATA byte) & 0xFF."""
44
+ hi = (self.num >> 8) & 0xFF
45
+ lo = self.num & 0xFF
46
+ s = PRE + FRM + hi + lo + (self.adr & 0xFF) + (self.sig & 0xFF) + (self.inst & 0xFF)
47
+ for b in self.data:
48
+ s += b & 0xFF
49
+ return 0xFF - (s & 0xFF)
50
+
51
+ # ---- encode ---------------------------------------------------------
52
+
53
+ def to_bytes(self) -> bytes:
54
+ """Serialize packet to on-wire bytes (auto-computes SUM)."""
55
+ num = self.num
56
+ out = bytearray(4 + num)
57
+ out[0] = PRE
58
+ out[1] = FRM
59
+ out[2] = (num >> 8) & 0xFF
60
+ out[3] = num & 0xFF
61
+ out[4] = self.adr & 0xFF
62
+ out[5] = self.sig & 0xFF
63
+ out[6] = self.inst & 0xFF
64
+ if self.data:
65
+ out[7 : 7 + len(self.data)] = self.data
66
+ out[7 + len(self.data)] = self.compute_sum()
67
+ out[8 + len(self.data)] = CR
68
+ return bytes(out)
69
+
70
+ # ---- decode ---------------------------------------------------------
71
+
72
+ @classmethod
73
+ def from_bytes(cls, raw: bytes) -> Packet97:
74
+ """Parse a single complete packet. Raises SpinelProtocolError if malformed."""
75
+ if len(raw) < MIN_LEN:
76
+ raise SpinelProtocolError(f"Packet too short: {len(raw)} < {MIN_LEN}")
77
+ if raw[0] != PRE:
78
+ raise SpinelProtocolError(f"Bad PRE 0x{raw[0]:02X}", ["PRE"])
79
+ if raw[1] != FRM:
80
+ raise SpinelProtocolError(f"Bad FRM 0x{raw[1]:02X}", ["FRM"])
81
+ num = (raw[2] << 8) | raw[3]
82
+ expected_len = 4 + num
83
+ if len(raw) != expected_len:
84
+ raise SpinelProtocolError(
85
+ f"Length mismatch: got {len(raw)}, NUM says {expected_len}", ["NUM"]
86
+ )
87
+ if raw[-1] != CR:
88
+ raise SpinelProtocolError(f"Missing CR (last byte 0x{raw[-1]:02X})", ["CR"])
89
+ data_len = num - 5
90
+ pkt = cls(
91
+ adr=raw[4],
92
+ sig=raw[5],
93
+ inst=raw[6],
94
+ data=bytes(raw[7 : 7 + data_len]) if data_len else b"",
95
+ )
96
+ expected_sum = pkt.compute_sum()
97
+ actual_sum = raw[7 + data_len]
98
+ if expected_sum != actual_sum:
99
+ pkt.sum_override = actual_sum
100
+ raise SpinelProtocolError(
101
+ f"Bad SUM: got 0x{actual_sum:02X}, expected 0x{expected_sum:02X}",
102
+ ["SUM"],
103
+ )
104
+ return pkt
105
+
106
+ # ---- helpers --------------------------------------------------------
107
+
108
+ def hex(self, sep: str = " ") -> str:
109
+ return self.to_bytes().hex(sep).upper()
110
+
111
+ def is_ack(self) -> bool:
112
+ """Packets with INST <= 0x0F are ACK responses (first DATA byte is ACK code)."""
113
+ return self.inst <= 0x0F
114
+
115
+ def ack_code(self) -> int | None:
116
+ if not self.is_ack() or not self.data:
117
+ return None
118
+ return self.data[0]
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Streaming parser — accepts arbitrary chunks, yields complete Packet97s.
123
+ # Tolerates leading garbage and packet boundaries split across chunks.
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ class Packet97StreamParser:
128
+ """In-memory bytearray buffer + iterative parser for Spinel 97 packets.
129
+
130
+ Drops single bytes when header doesn't match (resyncing on next PRE).
131
+ Returns invalid packets too when ``include_invalid=True`` (paired with errors list).
132
+ """
133
+
134
+ def __init__(self, *, include_invalid: bool = False, max_buffer: int = 1 << 20):
135
+ self._buf = bytearray()
136
+ self.include_invalid = include_invalid
137
+ self.max_buffer = max_buffer
138
+ self.dropped_bytes = 0
139
+ self.bad_packets = 0
140
+
141
+ def feed(self, chunk: bytes) -> list[tuple[Packet97, list[str]]]:
142
+ """Append chunk and return list of (packet, errors) tuples for any complete packets."""
143
+ if chunk:
144
+ self._buf.extend(chunk)
145
+ if len(self._buf) > self.max_buffer:
146
+ drop = len(self._buf) - self.max_buffer
147
+ del self._buf[:drop]
148
+ self.dropped_bytes += drop
149
+ out: list[tuple[Packet97, list[str]]] = []
150
+ while True:
151
+ result = self._try_parse_one()
152
+ if result is None:
153
+ break
154
+ out.append(result)
155
+ return out
156
+
157
+ def _try_parse_one(self) -> tuple[Packet97, list[str]] | None:
158
+ buf = self._buf
159
+ # Find PRE+FRM boundary
160
+ while len(buf) >= 2:
161
+ if buf[0] == PRE and buf[1] == FRM:
162
+ break
163
+ del buf[0]
164
+ self.dropped_bytes += 1
165
+ if len(buf) < MIN_LEN:
166
+ return None
167
+ num = (buf[2] << 8) | buf[3]
168
+ if num < 5:
169
+ del buf[0]
170
+ self.dropped_bytes += 1
171
+ return self._try_parse_one()
172
+ full_len = 4 + num
173
+ if len(buf) < full_len:
174
+ return None # need more data
175
+ if buf[full_len - 1] != CR:
176
+ # Not a real header — drop one byte and rescan
177
+ del buf[0]
178
+ self.dropped_bytes += 1
179
+ return self._try_parse_one()
180
+ raw = bytes(buf[:full_len])
181
+ del buf[:full_len]
182
+ try:
183
+ pkt = Packet97.from_bytes(raw)
184
+ return (pkt, [])
185
+ except SpinelProtocolError as exc:
186
+ self.bad_packets += 1
187
+ if not self.include_invalid:
188
+ # consume and continue; caller doesn't see it
189
+ return self._try_parse_one()
190
+ # Build a packet skeleton with the parsed fields if possible
191
+ data_len = num - 5
192
+ pkt = Packet97(
193
+ adr=raw[4],
194
+ sig=raw[5],
195
+ inst=raw[6],
196
+ data=bytes(raw[7 : 7 + data_len]) if data_len else b"",
197
+ sum_override=raw[7 + data_len],
198
+ )
199
+ return (pkt, exc.errors or ["INVALID"])
@@ -0,0 +1,154 @@
1
+ """Asyncio transports for Spinel: Serial (pyserial-asyncio) and TCP.
2
+
3
+ All transports expose the same async interface:
4
+ await transport.open()
5
+ await transport.write(data: bytes)
6
+ await transport.read(n: int = 4096) -> bytes # may return b'' if closed
7
+ await transport.close()
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from abc import ABC, abstractmethod
14
+ from typing import Self
15
+
16
+ import serial_asyncio # type: ignore[import-untyped]
17
+
18
+ from .errors import SpinelTransportError
19
+
20
+
21
+ class SpinelTransport(ABC):
22
+ @abstractmethod
23
+ async def open(self) -> None: ...
24
+
25
+ @abstractmethod
26
+ async def write(self, data: bytes) -> None: ...
27
+
28
+ @abstractmethod
29
+ async def read(self, n: int = 4096) -> bytes: ...
30
+
31
+ @abstractmethod
32
+ async def close(self) -> None: ...
33
+
34
+ async def __aenter__(self) -> Self:
35
+ await self.open()
36
+ return self
37
+
38
+ async def __aexit__(self, *exc: object) -> None:
39
+ await self.close()
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ class SerialTransport(SpinelTransport):
46
+ """USB-UART serial transport via pyserial-asyncio."""
47
+
48
+ def __init__(
49
+ self,
50
+ port: str,
51
+ baudrate: int = 115_200,
52
+ *,
53
+ bytesize: int = 8,
54
+ parity: str = "N",
55
+ stopbits: float = 1,
56
+ ) -> None:
57
+ self.port = port
58
+ self.baudrate = baudrate
59
+ self.bytesize = bytesize
60
+ self.parity = parity
61
+ self.stopbits = stopbits
62
+ self._reader: asyncio.StreamReader | None = None
63
+ self._writer: asyncio.StreamWriter | None = None
64
+
65
+ async def open(self) -> None:
66
+ try:
67
+ self._reader, self._writer = await serial_asyncio.open_serial_connection(
68
+ url=self.port,
69
+ baudrate=self.baudrate,
70
+ bytesize=self.bytesize,
71
+ parity=self.parity,
72
+ stopbits=self.stopbits,
73
+ )
74
+ except Exception as exc:
75
+ raise SpinelTransportError(f"Failed to open {self.port}: {exc}") from exc
76
+
77
+ async def write(self, data: bytes) -> None:
78
+ if self._writer is None:
79
+ raise SpinelTransportError("Serial transport not open")
80
+ try:
81
+ self._writer.write(data)
82
+ await self._writer.drain()
83
+ except OSError as exc:
84
+ raise SpinelTransportError(f"Serial write failed: {exc}") from exc
85
+
86
+ async def read(self, n: int = 4096) -> bytes:
87
+ if self._reader is None:
88
+ raise SpinelTransportError("Serial transport not open")
89
+ try:
90
+ return await self._reader.read(n)
91
+ except OSError as exc:
92
+ raise SpinelTransportError(f"Serial read failed: {exc}") from exc
93
+
94
+ async def close(self) -> None:
95
+ if self._writer is not None:
96
+ self._writer.close()
97
+ try:
98
+ await self._writer.wait_closed()
99
+ except (ConnectionError, OSError):
100
+ pass
101
+ self._reader = None
102
+ self._writer = None
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class TcpTransport(SpinelTransport):
109
+ """TCP transport (e.g. Lantronix XPort tunneling Spinel over Ethernet)."""
110
+
111
+ def __init__(self, host: str, port: int, *, connect_timeout: float = 5.0) -> None:
112
+ self.host = host
113
+ self.port = port
114
+ self.connect_timeout = connect_timeout
115
+ self._reader: asyncio.StreamReader | None = None
116
+ self._writer: asyncio.StreamWriter | None = None
117
+
118
+ async def open(self) -> None:
119
+ try:
120
+ self._reader, self._writer = await asyncio.wait_for(
121
+ asyncio.open_connection(self.host, self.port),
122
+ timeout=self.connect_timeout,
123
+ )
124
+ except (OSError, TimeoutError) as exc:
125
+ raise SpinelTransportError(
126
+ f"Failed to connect to {self.host}:{self.port}: {exc}"
127
+ ) from exc
128
+
129
+ async def write(self, data: bytes) -> None:
130
+ if self._writer is None:
131
+ raise SpinelTransportError("TCP transport not open")
132
+ try:
133
+ self._writer.write(data)
134
+ await self._writer.drain()
135
+ except (ConnectionError, OSError) as exc:
136
+ raise SpinelTransportError(f"TCP write failed: {exc}") from exc
137
+
138
+ async def read(self, n: int = 4096) -> bytes:
139
+ if self._reader is None:
140
+ raise SpinelTransportError("TCP transport not open")
141
+ try:
142
+ return await self._reader.read(n)
143
+ except (ConnectionError, OSError) as exc:
144
+ raise SpinelTransportError(f"TCP read failed: {exc}") from exc
145
+
146
+ async def close(self) -> None:
147
+ if self._writer is not None:
148
+ self._writer.close()
149
+ try:
150
+ await self._writer.wait_closed()
151
+ except (ConnectionError, OSError):
152
+ pass
153
+ self._reader = None
154
+ self._writer = None