sockcan 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.
- sockcan-0.1.0/.github/workflows/publish-to-pypi.yml +51 -0
- sockcan-0.1.0/PKG-INFO +43 -0
- sockcan-0.1.0/README.md +29 -0
- sockcan-0.1.0/pyproject.toml +25 -0
- sockcan-0.1.0/src/sockcan/__init__.py +26 -0
- sockcan-0.1.0/src/sockcan/_protocol.py +234 -0
- sockcan-0.1.0/src/sockcan/benchmarks/__init__.py +12 -0
- sockcan-0.1.0/src/sockcan/benchmarks/__main__.py +47 -0
- sockcan-0.1.0/src/sockcan/benchmarks/_bench.py +49 -0
- sockcan-0.1.0/src/sockcan/fixtures/__init__.py +23 -0
- sockcan-0.1.0/src/sockcan/fixtures/_bus.py +69 -0
- sockcan-0.1.0/src/sockcan/fixtures/_messages.py +34 -0
- sockcan-0.1.0/src/sockcan/fixtures/py.typed +0 -0
- sockcan-0.1.0/src/sockcan/py.typed +0 -0
- sockcan-0.1.0/tests/test_fixtures.py +43 -0
- sockcan-0.1.0/tests/test_protocol.py +120 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Publish sockan to PyPi
|
|
2
|
+
|
|
3
|
+
on: push
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
build:
|
|
7
|
+
name: Build distribution 📦
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- name: Set up Python
|
|
13
|
+
uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.x"
|
|
16
|
+
|
|
17
|
+
- name: Install pypa/build
|
|
18
|
+
run: >-
|
|
19
|
+
python3 -m
|
|
20
|
+
pip install
|
|
21
|
+
build
|
|
22
|
+
--user
|
|
23
|
+
- name: Build a binary wheel and a source tarball
|
|
24
|
+
run: python3 -m build
|
|
25
|
+
- name: Store the distribution packages
|
|
26
|
+
uses: actions/upload-artifact@v4
|
|
27
|
+
with:
|
|
28
|
+
name: python-package-distributions
|
|
29
|
+
path: dist/
|
|
30
|
+
|
|
31
|
+
publish-to-pypi:
|
|
32
|
+
name: >-
|
|
33
|
+
Publish Python 🐍 distribution 📦 to PyPI
|
|
34
|
+
if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
|
|
35
|
+
needs:
|
|
36
|
+
- build
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
environment:
|
|
39
|
+
name: pypi
|
|
40
|
+
url: https://pypi.org/p/sockan
|
|
41
|
+
permissions:
|
|
42
|
+
id-token: write # IMPORTANT: mandatory for trusted publishing
|
|
43
|
+
|
|
44
|
+
steps:
|
|
45
|
+
- name: Download all the dists
|
|
46
|
+
uses: actions/download-artifact@v4
|
|
47
|
+
with:
|
|
48
|
+
name: python-package-distributions
|
|
49
|
+
path: dist/
|
|
50
|
+
- name: Publish distribution 📦 to PyPI
|
|
51
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
sockcan-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sockcan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Provides a socketcan-alike interface other arbitrary CAN drivers
|
|
5
|
+
Project-URL: Homepage, https://github.com/Hedwyn/sockcan
|
|
6
|
+
Author: Baptiste Pestourie
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: hypothesis; extra == 'test'
|
|
11
|
+
Requires-Dist: pytest; extra == 'test'
|
|
12
|
+
Requires-Dist: python-can; extra == 'test'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# sockcan
|
|
16
|
+
|
|
17
|
+
Provides a socketcan-alike interface other arbitrary CAN drivers.
|
|
18
|
+
|
|
19
|
+
## Running benchmarks
|
|
20
|
+
|
|
21
|
+
Benchmarks can be run with `python -m sockcan.benchmarks`:
|
|
22
|
+
|
|
23
|
+
```shell
|
|
24
|
+
python -m sockcan.benchmarks --help
|
|
25
|
+
Usage: python -m sockcan.benchmarks [OPTIONS]
|
|
26
|
+
|
|
27
|
+
Runs the benchmarks interactively
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
-r, --rounds INTEGER
|
|
31
|
+
-b, --batch-size INTEGER
|
|
32
|
+
-v, --verbose
|
|
33
|
+
--help Show this message and exit.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Running tests
|
|
37
|
+
|
|
38
|
+
Tests are based on `pytest` and `hypothesis`. Make sure to install this package with `test` extra (`pip install .\[test\`).<br>
|
|
39
|
+
You can show hypothesis stats with `--hypothesis-show-statistics`:
|
|
40
|
+
|
|
41
|
+
```shell
|
|
42
|
+
python -m pytest -vv --hypothesis-show-statistics
|
|
43
|
+
```
|
sockcan-0.1.0/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# sockcan
|
|
2
|
+
|
|
3
|
+
Provides a socketcan-alike interface other arbitrary CAN drivers.
|
|
4
|
+
|
|
5
|
+
## Running benchmarks
|
|
6
|
+
|
|
7
|
+
Benchmarks can be run with `python -m sockcan.benchmarks`:
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
python -m sockcan.benchmarks --help
|
|
11
|
+
Usage: python -m sockcan.benchmarks [OPTIONS]
|
|
12
|
+
|
|
13
|
+
Runs the benchmarks interactively
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
-r, --rounds INTEGER
|
|
17
|
+
-b, --batch-size INTEGER
|
|
18
|
+
-v, --verbose
|
|
19
|
+
--help Show this message and exit.
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Running tests
|
|
23
|
+
|
|
24
|
+
Tests are based on `pytest` and `hypothesis`. Make sure to install this package with `test` extra (`pip install .\[test\`).<br>
|
|
25
|
+
You can show hypothesis stats with `--hypothesis-show-statistics`:
|
|
26
|
+
|
|
27
|
+
```shell
|
|
28
|
+
python -m pytest -vv --hypothesis-show-statistics
|
|
29
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sockcan"
|
|
7
|
+
# setting version to VCS so hatch will automatically use tags for versioning
|
|
8
|
+
dynamic = ["version"]
|
|
9
|
+
description = "Provides a socketcan-alike interface other arbitrary CAN drivers"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "Baptiste Pestourie" }]
|
|
13
|
+
dependencies = ["click"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["hypothesis", "pytest", "python-can"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/Hedwyn/sockcan"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.version]
|
|
22
|
+
source = "vcs"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/sockcan"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralizes imports.
|
|
3
|
+
|
|
4
|
+
@date: 19.03.2026
|
|
5
|
+
@author: Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._protocol import (
|
|
11
|
+
RecvFn,
|
|
12
|
+
SocketcanConfig,
|
|
13
|
+
SocketcanFd,
|
|
14
|
+
build_recv_func,
|
|
15
|
+
build_send_func,
|
|
16
|
+
connect_to_socketcan,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"RecvFn",
|
|
21
|
+
"SocketcanConfig",
|
|
22
|
+
"SocketcanFd",
|
|
23
|
+
"build_recv_func",
|
|
24
|
+
"build_send_func",
|
|
25
|
+
"connect_to_socketcan",
|
|
26
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implements the binary protoco defined by socketcan.
|
|
3
|
+
|
|
4
|
+
@date: 19.03.2026
|
|
5
|
+
@author: Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import socket
|
|
11
|
+
import struct
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum, auto
|
|
15
|
+
from functools import lru_cache, partial
|
|
16
|
+
from typing import Any, NamedTuple, NewType, Protocol, cast
|
|
17
|
+
|
|
18
|
+
SocketcanFd = NewType("SocketcanFd", socket.socket)
|
|
19
|
+
|
|
20
|
+
RECEIVED_TIMESTAMP_STRUCT = struct.Struct("@ll")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class CanMessage:
|
|
25
|
+
"""
|
|
26
|
+
Container for CAN message data that matches field naming
|
|
27
|
+
use by python-can's Message.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
arbitration_id: int
|
|
31
|
+
data: bytes
|
|
32
|
+
is_extended_id: bool
|
|
33
|
+
timestamp: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_received_ancillary_buf_size() -> int:
|
|
37
|
+
"""
|
|
38
|
+
Ancillary data size is platform dependant
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
from socket import CMSG_SPACE # noqa: PLC0415
|
|
42
|
+
|
|
43
|
+
return CMSG_SPACE(RECEIVED_TIMESTAMP_STRUCT.size)
|
|
44
|
+
except ImportError:
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LoopbackMode(Enum):
|
|
49
|
+
"""
|
|
50
|
+
Whether we receive our own messages.
|
|
51
|
+
|
|
52
|
+
If using .FOR_OTHER_SOCKS: others sockets on the same physical CAN device
|
|
53
|
+
will receive our TX messages, but not us.
|
|
54
|
+
If .ON: the socket will receive its own message.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
OFF = auto()
|
|
58
|
+
FOR_OTHER_SOCKS = auto()
|
|
59
|
+
ON = auto()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LazyCanMessage(NamedTuple):
|
|
63
|
+
arbitration_id: int
|
|
64
|
+
data: bytes
|
|
65
|
+
raw_link_data: bytes
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- Constants --- #
|
|
69
|
+
CANFD_MTU = 72
|
|
70
|
+
PF_CAN = 29
|
|
71
|
+
CAN_RAW = 1
|
|
72
|
+
SOL_CAN_BASE = 100
|
|
73
|
+
SOL_CAN_RAW = SOL_CAN_BASE + CAN_RAW
|
|
74
|
+
CAN_RAW_RECV_OWN_MSGS = 4
|
|
75
|
+
|
|
76
|
+
SO_TIMESTAMPNS = 35
|
|
77
|
+
CAN_EFF_FLAG = 0x80000000
|
|
78
|
+
CAN_RAW_LOOPBACK = 3
|
|
79
|
+
CAN_FRAME_HEADER_STRUCT = struct.Struct("=IBB2x")
|
|
80
|
+
CAN_EXTENSION_MASK = 0x07FFF800
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True, frozen=True)
|
|
84
|
+
class SocketcanConfig:
|
|
85
|
+
"""
|
|
86
|
+
Options to configure the socketcan connection.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
channel: str = "can0"
|
|
90
|
+
loopback: LoopbackMode = LoopbackMode.FOR_OTHER_SOCKS
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def connect_to_socketcan(config: SocketcanConfig) -> SocketcanFd:
|
|
94
|
+
"""
|
|
95
|
+
Creates a socketcan socket according to `config`.
|
|
96
|
+
"""
|
|
97
|
+
sock = socket.socket(PF_CAN, socket.SOCK_RAW, CAN_RAW)
|
|
98
|
+
sock.setsockopt(
|
|
99
|
+
SOL_CAN_RAW,
|
|
100
|
+
CAN_RAW_RECV_OWN_MSGS,
|
|
101
|
+
1 if config.loopback == LoopbackMode.ON else 0,
|
|
102
|
+
)
|
|
103
|
+
sock.setsockopt(
|
|
104
|
+
SOL_CAN_RAW,
|
|
105
|
+
CAN_RAW_LOOPBACK,
|
|
106
|
+
1 if config.loopback == LoopbackMode.FOR_OTHER_SOCKS else 0,
|
|
107
|
+
)
|
|
108
|
+
sock.setsockopt(socket.SOL_SOCKET, SO_TIMESTAMPNS, 1)
|
|
109
|
+
sock.bind((config.channel,))
|
|
110
|
+
return cast("SocketcanFd", sock)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
type _CMSG = tuple[int, int, bytes]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RecvMsgFn(Protocol):
|
|
117
|
+
"""
|
|
118
|
+
Any recv function which signatures complies with `recvmsg` method of sockets.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __call__(
|
|
122
|
+
self,
|
|
123
|
+
bufsize: int,
|
|
124
|
+
ancbufsize: int = 0,
|
|
125
|
+
flags: int = 0,
|
|
126
|
+
/,
|
|
127
|
+
) -> tuple[bytes, list[_CMSG], int, Any]: ...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
type HeaderUnpack = Callable[[bytes], tuple[int, int, int]]
|
|
131
|
+
type TimestampUnpack = Callable[[bytes], tuple[int, int]]
|
|
132
|
+
|
|
133
|
+
type HeaderPack = Callable[[int, int, int], bytes]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _socketcan_recv(
|
|
137
|
+
recv_fn: RecvMsgFn,
|
|
138
|
+
custom_mask: int = 0xFFFF_FFFF,
|
|
139
|
+
exc_class: type[Exception] = OSError,
|
|
140
|
+
# Note: all parameters below are injected as default arguments so they are accessed faster
|
|
141
|
+
# they are not meants to be overriden, hence the prefix '__'
|
|
142
|
+
_ancillary_data_size: int = get_received_ancillary_buf_size(),
|
|
143
|
+
# Warning: these defaulted parameters are mainly there
|
|
144
|
+
# to inject the constants in local scope and speed up their access.
|
|
145
|
+
_header_unpack: HeaderUnpack = CAN_FRAME_HEADER_STRUCT.unpack_from,
|
|
146
|
+
_timestamp_unpack: TimestampUnpack = RECEIVED_TIMESTAMP_STRUCT.unpack_from,
|
|
147
|
+
_canfd_mtu: int = CANFD_MTU,
|
|
148
|
+
_can_eff_flag: int = CAN_EFF_FLAG,
|
|
149
|
+
) -> CanMessage:
|
|
150
|
+
"""
|
|
151
|
+
Captures a message from the CAN bus and runs partial decoding.
|
|
152
|
+
Unpacks the data, arbitration ID and timestamp andf leaves all the other metadata undecoded.
|
|
153
|
+
Metadata will only be decoded on access.
|
|
154
|
+
"""
|
|
155
|
+
# Fetching the Arb ID, DLC and Data
|
|
156
|
+
try:
|
|
157
|
+
cf, ancillary_data, *_ = recv_fn(_canfd_mtu, _ancillary_data_size)
|
|
158
|
+
except OSError as error:
|
|
159
|
+
msg = f"Error receiving: {error.strerror}"
|
|
160
|
+
raise exc_class(msg) from error
|
|
161
|
+
|
|
162
|
+
can_id, can_dlc, _ = _header_unpack(cf)
|
|
163
|
+
# is_extended = bool(can_id & _can_eff_flag)
|
|
164
|
+
# Note: `'not not' is faster than bool
|
|
165
|
+
is_extended = not not (can_id & _can_eff_flag) # noqa: SIM208
|
|
166
|
+
can_id = can_id & 0x1FFFFFFF
|
|
167
|
+
|
|
168
|
+
data = cf[8 : 8 + can_dlc]
|
|
169
|
+
can_id = can_id & custom_mask
|
|
170
|
+
|
|
171
|
+
assert ancillary_data, "ancillary data was not enabled on the socket"
|
|
172
|
+
cmsg_data = ancillary_data[0][2]
|
|
173
|
+
|
|
174
|
+
seconds, nanoseconds = _timestamp_unpack(cmsg_data)
|
|
175
|
+
timestamp = seconds + nanoseconds * 1e-9
|
|
176
|
+
# updating data
|
|
177
|
+
return CanMessage(can_id, data, is_extended, timestamp)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
type RecvFn = Callable[[], CanMessage]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_recv_func(fd: SocketcanFd) -> RecvFn:
|
|
184
|
+
"""
|
|
185
|
+
Builds the receive function for socketcan socket `fd`.
|
|
186
|
+
"""
|
|
187
|
+
return partial(_socketcan_recv, fd.recvmsg)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@lru_cache(maxsize=1024)
|
|
191
|
+
def build_tx_header(
|
|
192
|
+
can_id: int,
|
|
193
|
+
dlc: int,
|
|
194
|
+
*,
|
|
195
|
+
is_extended_id: bool = False,
|
|
196
|
+
_header_pack: HeaderPack = CAN_FRAME_HEADER_STRUCT.pack,
|
|
197
|
+
_can_eff_flag: int = CAN_EFF_FLAG,
|
|
198
|
+
_can_extension_mask: int = CAN_EXTENSION_MASK,
|
|
199
|
+
) -> bytes:
|
|
200
|
+
"""
|
|
201
|
+
Encodes the CAN header bytes for a given ID and DLC
|
|
202
|
+
"""
|
|
203
|
+
if is_extended_id or (can_id & _can_extension_mask) > 0:
|
|
204
|
+
can_id |= _can_eff_flag
|
|
205
|
+
|
|
206
|
+
return _header_pack(can_id, dlc, 0)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class SendMsgFn(Protocol):
|
|
210
|
+
def __call__(self, data: bytes, flags: int = 0, /) -> int: ...
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def send_can_message(
|
|
214
|
+
send_fn: SendMsgFn,
|
|
215
|
+
arbitration_id: int,
|
|
216
|
+
data: bytes,
|
|
217
|
+
is_extended: bool = False, # noqa: FBT001, FBT002
|
|
218
|
+
) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Sends a can message specified with `data` and `arbitration_id`
|
|
221
|
+
using the socket send function `send_fn`
|
|
222
|
+
"""
|
|
223
|
+
header = build_tx_header(arbitration_id, data.__len__(), is_extended_id=is_extended)
|
|
224
|
+
send_fn(header + data.ljust(8, b"\0"))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
type SendFn = Callable[[int, bytes, bool], None]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def build_send_func(fd: SocketcanFd) -> SendFn:
|
|
231
|
+
"""
|
|
232
|
+
Builds the send function for socketcan socket `fd`.
|
|
233
|
+
"""
|
|
234
|
+
return partial(send_can_message, fd.send)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runs benchmarks against python-can.
|
|
3
|
+
|
|
4
|
+
@date: 19.03.2026
|
|
5
|
+
@author: Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pstats
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from sockcan._protocol import SocketcanConfig, build_recv_func, connect_to_socketcan
|
|
15
|
+
from sockcan.fixtures import vcan_bus
|
|
16
|
+
|
|
17
|
+
from ._bench import bench
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command()
|
|
21
|
+
@click.option("-r", "--rounds", default=200, type=int)
|
|
22
|
+
@click.option("-b", "--batch-size", default=100, type=int)
|
|
23
|
+
@click.option("-v", "--verbose", is_flag=True)
|
|
24
|
+
def cli(*, rounds: int, batch_size: int, verbose: bool) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Runs the benchmarks interactively
|
|
27
|
+
"""
|
|
28
|
+
with vcan_bus() as tx_bus, vcan_bus() as rx_bus:
|
|
29
|
+
python_can_profile = bench(rx_bus.recv, tx_bus, batch_size=batch_size, total_rounds=rounds)
|
|
30
|
+
|
|
31
|
+
if verbose:
|
|
32
|
+
python_can_profile.print_stats()
|
|
33
|
+
|
|
34
|
+
sockcan_sock = connect_to_socketcan(SocketcanConfig(channel="vcan0"))
|
|
35
|
+
recv_fn = build_recv_func(sockcan_sock)
|
|
36
|
+
sockcan_profile = bench(recv_fn, tx_bus, batch_size=batch_size, total_rounds=rounds)
|
|
37
|
+
if verbose:
|
|
38
|
+
sockcan_profile.print_stats()
|
|
39
|
+
|
|
40
|
+
python_can_stats = pstats.Stats(python_can_profile)
|
|
41
|
+
sockcan_stats = pstats.Stats(sockcan_profile)
|
|
42
|
+
ratio = python_can_stats.total_tt / sockcan_stats.total_tt # type: ignore[attr-defined]
|
|
43
|
+
click.echo(f"Performed {ratio:.02f} x faster than python-can")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
cli()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implements benchmarks against python-can.
|
|
3
|
+
|
|
4
|
+
@date: 19.03.2026
|
|
5
|
+
@author: Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import cProfile
|
|
11
|
+
from cProfile import Profile
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import can
|
|
15
|
+
from can import Message
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from sockcan import RecvFn
|
|
21
|
+
from sockcan.fixtures import SocketcanBus
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TEST_MSG = can.Message(arbitration_id=0x200, data=b"\x00\x01\x02\x03\x04\x05\x06\x07")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bench(
|
|
28
|
+
recv_fn: RecvFn | Callable[..., object],
|
|
29
|
+
tx_bus: SocketcanBus,
|
|
30
|
+
test_msg: Message = TEST_MSG,
|
|
31
|
+
batch_size: int = 100,
|
|
32
|
+
total_rounds: int = 100,
|
|
33
|
+
) -> Profile:
|
|
34
|
+
"""
|
|
35
|
+
Profiles the receiver from this project against python-can.
|
|
36
|
+
`batch_size` should be small enough to fit in the receiver buffer,
|
|
37
|
+
as they will be sent at once without consuming them.
|
|
38
|
+
Total messages sent is equal to batch_size * total_rounds.
|
|
39
|
+
"""
|
|
40
|
+
profile = cProfile.Profile()
|
|
41
|
+
for _ in range(total_rounds):
|
|
42
|
+
for _ in range(batch_size):
|
|
43
|
+
tx_bus.send(test_msg)
|
|
44
|
+
|
|
45
|
+
profile.enable()
|
|
46
|
+
for _ in range(batch_size):
|
|
47
|
+
recv_fn()
|
|
48
|
+
profile.disable()
|
|
49
|
+
return profile
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
All fixtures from this package avaiable for testing.
|
|
3
|
+
|
|
4
|
+
@date: 19.03.2026
|
|
5
|
+
@author: Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from can.interfaces.socketcan import SocketcanBus
|
|
11
|
+
|
|
12
|
+
from ._bus import has_vcan, rx_can_bus, skip_if_no_vcan, tx_can_bus, vcan_bus
|
|
13
|
+
from ._messages import can_messages
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SocketcanBus",
|
|
17
|
+
"can_messages",
|
|
18
|
+
"has_vcan",
|
|
19
|
+
"rx_can_bus",
|
|
20
|
+
"skip_if_no_vcan",
|
|
21
|
+
"tx_can_bus",
|
|
22
|
+
"vcan_bus",
|
|
23
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Small fixtures around VCAN bus.
|
|
3
|
+
|
|
4
|
+
@date: 19.03.2026
|
|
5
|
+
@author: Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import can
|
|
15
|
+
import pytest
|
|
16
|
+
from can.interfaces.socketcan import SocketcanBus
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Generator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextlib.contextmanager
|
|
23
|
+
def vcan_bus(channel: str = "vcan0") -> Generator[SocketcanBus, None, None]:
|
|
24
|
+
"""
|
|
25
|
+
A context manager around a virtual can bus.
|
|
26
|
+
"""
|
|
27
|
+
with can.Bus(interface="socketcan", channel=channel) as bus:
|
|
28
|
+
assert isinstance(bus, SocketcanBus)
|
|
29
|
+
yield bus
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def has_vcan(channel: str = "vcan0") -> bool:
|
|
33
|
+
"""
|
|
34
|
+
Whether vcan0 is available on the system.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
with vcan_bus(channel=channel):
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
except OSError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def skip_if_no_vcan[T: Callable[..., None]]() -> Callable[[T], T]:
|
|
45
|
+
"""
|
|
46
|
+
A pytest mark that skips the test if no vcan is available.
|
|
47
|
+
"""
|
|
48
|
+
return pytest.mark.skipif(
|
|
49
|
+
not has_vcan(),
|
|
50
|
+
reason="VCAN channel (vcan0) needs to be available for testing",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def tx_can_bus() -> Generator[SocketcanBus, None, None]:
|
|
56
|
+
"""
|
|
57
|
+
Bus fixture for testing
|
|
58
|
+
"""
|
|
59
|
+
with vcan_bus() as bus:
|
|
60
|
+
yield bus
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def rx_can_bus() -> Generator[SocketcanBus, None, None]:
|
|
65
|
+
"""
|
|
66
|
+
Bus fixture for testing
|
|
67
|
+
"""
|
|
68
|
+
with vcan_bus() as bus:
|
|
69
|
+
yield bus
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementss CAN messages random generation strategies using hypothesis.
|
|
3
|
+
|
|
4
|
+
@date: 19.03.2026
|
|
5
|
+
@author: Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import can
|
|
11
|
+
from hypothesis import strategies as st
|
|
12
|
+
|
|
13
|
+
CAN_11BIT_ID_MASK = 0x3FF
|
|
14
|
+
CAN_29BIT_ID_MASK = 0x1FFF_FFFF
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@st.composite
|
|
18
|
+
def can_messages(draw: st.DrawFn) -> can.Message:
|
|
19
|
+
"""
|
|
20
|
+
A composite strategy generating random CAN messages.
|
|
21
|
+
"""
|
|
22
|
+
is_extended = draw(st.booleans())
|
|
23
|
+
id_bytes = draw(st.binary(min_size=0, max_size=4))
|
|
24
|
+
data = draw(st.binary(min_size=0, max_size=8))
|
|
25
|
+
dlc = len(data)
|
|
26
|
+
can_id_mask = CAN_29BIT_ID_MASK if is_extended else CAN_11BIT_ID_MASK
|
|
27
|
+
normalized_id = int.from_bytes(id_bytes) & can_id_mask
|
|
28
|
+
|
|
29
|
+
return can.Message(
|
|
30
|
+
arbitration_id=normalized_id,
|
|
31
|
+
is_extended_id=is_extended,
|
|
32
|
+
dlc=dlc,
|
|
33
|
+
data=data,
|
|
34
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sanity checks for the tests fixtures.
|
|
3
|
+
|
|
4
|
+
@date: 18.03.2026
|
|
5
|
+
@author; Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from hypothesis import HealthCheck, given, settings
|
|
13
|
+
|
|
14
|
+
from sockcan.fixtures import SocketcanBus, can_messages, rx_can_bus, skip_if_no_vcan, tx_can_bus
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from can import Message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# fixtures
|
|
21
|
+
_ = rx_can_bus
|
|
22
|
+
_ = tx_can_bus
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@given(can_message=can_messages())
|
|
26
|
+
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
27
|
+
@skip_if_no_vcan()
|
|
28
|
+
def test_random_message_sanity(
|
|
29
|
+
can_message: Message,
|
|
30
|
+
rx_can_bus: SocketcanBus,
|
|
31
|
+
tx_can_bus: SocketcanBus,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Verifies that the auto-generated messages are received on the other side
|
|
35
|
+
as expected.
|
|
36
|
+
"""
|
|
37
|
+
tx_can_bus.send(can_message)
|
|
38
|
+
obtained = rx_can_bus.recv()
|
|
39
|
+
assert obtained is not None
|
|
40
|
+
assert obtained.arbitration_id == can_message.arbitration_id
|
|
41
|
+
assert obtained.data == can_message.data
|
|
42
|
+
assert obtained.dlc == can_message.dlc
|
|
43
|
+
assert obtained.is_extended_id is can_message.is_extended_id
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Verifies the implementation of socketcan protocol.
|
|
3
|
+
|
|
4
|
+
@date: 19.03.2026
|
|
5
|
+
@author: Baptiste Pestourie
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import socket
|
|
12
|
+
from typing import TYPE_CHECKING, Literal, cast
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from hypothesis import HealthCheck, given, settings
|
|
16
|
+
|
|
17
|
+
from sockcan import SocketcanConfig, SocketcanFd, build_recv_func, connect_to_socketcan
|
|
18
|
+
from sockcan._protocol import build_send_func
|
|
19
|
+
from sockcan.fixtures import can_messages, rx_can_bus, skip_if_no_vcan, tx_can_bus, vcan_bus
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Generator
|
|
23
|
+
from socket import socket as Socket # noqa: N812
|
|
24
|
+
|
|
25
|
+
from can import Message as PyCanMessage
|
|
26
|
+
from can.interfaces.socketcan import SocketcanBus
|
|
27
|
+
from pytest import FixtureRequest # noqa: PT013
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ = tx_can_bus
|
|
31
|
+
_ = rx_can_bus
|
|
32
|
+
|
|
33
|
+
# Whether the socketcan socket should be created using python-can
|
|
34
|
+
# or our own implementation
|
|
35
|
+
SOCKET_PROVIDERS = ["python-can", "sockcan"]
|
|
36
|
+
type SocketProvider = Literal["python-can", "sockcan"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextlib.contextmanager
|
|
40
|
+
def get_socketcan_sock(
|
|
41
|
+
provider: SocketProvider = "python-can",
|
|
42
|
+
) -> Generator[SocketcanFd, None, None]:
|
|
43
|
+
if provider == "python-can":
|
|
44
|
+
with vcan_bus() as bus:
|
|
45
|
+
yield cast("SocketcanFd", bus.socket)
|
|
46
|
+
else: # sockcan
|
|
47
|
+
sock = connect_to_socketcan(SocketcanConfig(channel="vcan0"))
|
|
48
|
+
try:
|
|
49
|
+
yield sock
|
|
50
|
+
finally:
|
|
51
|
+
sock.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def rx_sock(request: FixtureRequest) -> Generator[SocketcanFd, None, None]:
|
|
56
|
+
"""
|
|
57
|
+
The socket that should be used to test message reception.
|
|
58
|
+
Built by default using python-can. If parametrizing provider as sockcan,
|
|
59
|
+
builds it using our implementation instead.
|
|
60
|
+
"""
|
|
61
|
+
provider = cast("SocketProvider", getattr(request, "param", "python-can"))
|
|
62
|
+
with get_socketcan_sock(provider=provider) as sock:
|
|
63
|
+
yield sock
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture
|
|
67
|
+
def tx_sock(request: FixtureRequest) -> Generator[SocketcanFd, None, None]:
|
|
68
|
+
"""
|
|
69
|
+
The socket that should be used to test message reception.
|
|
70
|
+
Built by default using python-can. If parametrizing provider as sockcan,
|
|
71
|
+
builds it using our implementation instead.
|
|
72
|
+
"""
|
|
73
|
+
provider = cast("SocketProvider", getattr(request, "param", "python-can"))
|
|
74
|
+
with get_socketcan_sock(provider=provider) as sock:
|
|
75
|
+
yield sock
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize("rx_sock", ["sockcan"], indirect=True)
|
|
79
|
+
def test_sock_sanity(rx_sock: Socket) -> None:
|
|
80
|
+
assert isinstance(rx_sock, socket.socket)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@given(can_message=can_messages())
|
|
84
|
+
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
85
|
+
@pytest.mark.parametrize("rx_sock", SOCKET_PROVIDERS, indirect=True)
|
|
86
|
+
@skip_if_no_vcan()
|
|
87
|
+
def test_recv_message(
|
|
88
|
+
can_message: PyCanMessage,
|
|
89
|
+
tx_can_bus: SocketcanBus,
|
|
90
|
+
rx_sock: SocketcanFd,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Sends a message using python-can and verifies that the implementation in this package
|
|
94
|
+
receives it properly.
|
|
95
|
+
"""
|
|
96
|
+
recv_fn = build_recv_func(rx_sock)
|
|
97
|
+
tx_can_bus.send(can_message)
|
|
98
|
+
obtained = recv_fn()
|
|
99
|
+
assert obtained.arbitration_id == can_message.arbitration_id
|
|
100
|
+
assert obtained.is_extended_id == can_message.is_extended_id
|
|
101
|
+
assert obtained.data == can_message.data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@given(can_message=can_messages())
|
|
105
|
+
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
|
|
106
|
+
@pytest.mark.parametrize("tx_sock", SOCKET_PROVIDERS, indirect=True)
|
|
107
|
+
@skip_if_no_vcan()
|
|
108
|
+
def test_send_message(
|
|
109
|
+
can_message: PyCanMessage,
|
|
110
|
+
rx_can_bus: SocketcanBus,
|
|
111
|
+
tx_sock: SocketcanFd,
|
|
112
|
+
) -> None:
|
|
113
|
+
send_fn = build_send_func(tx_sock)
|
|
114
|
+
send_fn(can_message.arbitration_id, can_message.data, can_message.is_extended_id) # type: ignore[arg-type]
|
|
115
|
+
obtained = rx_can_bus.recv(timeout=0)
|
|
116
|
+
assert obtained is not None, "Message was not sent"
|
|
117
|
+
|
|
118
|
+
assert obtained.arbitration_id == can_message.arbitration_id
|
|
119
|
+
assert obtained.is_extended_id == can_message.is_extended_id
|
|
120
|
+
assert obtained.data == can_message.data
|