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.
@@ -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
+ ```
@@ -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,12 @@
1
+ """
2
+ Centralizes imports.
3
+
4
+ @date: 19.03.2026
5
+ @author: Baptiste Pestourie
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._bench import bench
11
+
12
+ __all__ = ["bench"]
@@ -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