cantcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cantcp/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ r"""Python library for the cantcp protocol.
2
+
3
+ The package carries CAN frames only: stream framing of raw SocketCAN frames
4
+ in a byte stream, plus parsing, encoding and decoding of Linux SocketCAN
5
+ frames. Connection setup is plain TCP: handshakes, subscriptions, keepalives,
6
+ statistics and health checks are not part of the protocol.
7
+
8
+ Quick start:
9
+
10
+ >>> import io
11
+ >>> import cantcp
12
+ >>> stream = io.BytesIO()
13
+ >>> frame = cantcp.Frame(type=cantcp.Type.CLASSIC, id=0x123, data=bytes.fromhex("dead"))
14
+ >>> cantcp.Encoder(stream).encode_frame(frame)
15
+ >>> print(next(iter(cantcp.Decoder(io.BytesIO(stream.getvalue())))))
16
+ Frame{Type:CAN, ID:0x123, Flags:none, DLC:2, Data:dead}
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from importlib.metadata import PackageNotFoundError, version
22
+
23
+ from cantcp.crc_cover import CrcCover
24
+ from cantcp.decoder import Decoder
25
+ from cantcp.encoder import Encoder
26
+ from cantcp.errors import (
27
+ BadDLCError,
28
+ BadFlagsError,
29
+ BadIDError,
30
+ BadLenError,
31
+ BadTypeError,
32
+ CantcpError,
33
+ FrameLenError,
34
+ ReservedError,
35
+ ShortWriteError,
36
+ TruncatedError,
37
+ )
38
+ from cantcp.flags import Flag
39
+ from cantcp.frame import Frame
40
+ from cantcp.logger import TRACE
41
+ from cantcp.parser import Parser
42
+ from cantcp.policy import BadFramePolicy
43
+ from cantcp.stats import Stats
44
+ from cantcp.type import Type
45
+ from cantcp.validate import validate_raw
46
+
47
+ try:
48
+ __version__ = version("cantcp")
49
+ except PackageNotFoundError: # pragma: no cover - the package is not installed
50
+ __version__ = "0.0.0"
51
+
52
+ __all__ = [
53
+ "TRACE",
54
+ "BadDLCError",
55
+ "BadFlagsError",
56
+ "BadFramePolicy",
57
+ "BadIDError",
58
+ "BadLenError",
59
+ "BadTypeError",
60
+ "CantcpError",
61
+ "CrcCover",
62
+ "Decoder",
63
+ "Encoder",
64
+ "Flag",
65
+ "Frame",
66
+ "FrameLenError",
67
+ "Parser",
68
+ "ReservedError",
69
+ "ShortWriteError",
70
+ "Stats",
71
+ "TruncatedError",
72
+ "Type",
73
+ "validate_raw",
74
+ ]
cantcp/crc8.py ADDED
@@ -0,0 +1,39 @@
1
+ """Table-driven CRC-8 with a configurable polynomial.
2
+
3
+ The default polynomial 0x07 is CRC-8/SMBus: init 0x00, no input or output
4
+ reflection, no final XOR. The table is built per instance, nothing is shared
5
+ globally.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ _TABLE_SIZE = 256
11
+ _BITS_PER_BYTE = 8
12
+ _BYTE_MASK = 0xFF
13
+ _TOP_BIT = 0x80
14
+
15
+
16
+ class _CRC8:
17
+ """A CRC-8 calculator with its own lookup table."""
18
+
19
+ __slots__ = ("_table",)
20
+
21
+ def __init__(self, poly: int) -> None:
22
+ table = []
23
+ for index in range(_TABLE_SIZE):
24
+ value = index
25
+ for _ in range(_BITS_PER_BYTE):
26
+ if value & _TOP_BIT:
27
+ value = (value << 1) ^ poly
28
+ else:
29
+ value <<= 1
30
+ value &= _BYTE_MASK
31
+ table.append(value)
32
+ self._table = tuple(table)
33
+
34
+ def sum(self, data: bytes) -> int:
35
+ """Return the CRC-8 of ``data``."""
36
+ value = 0
37
+ for byte in data:
38
+ value = self._table[value ^ byte]
39
+ return value
cantcp/crc_cover.py ADDED
@@ -0,0 +1,15 @@
1
+ """Which part of a packet the CRC-8 covers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class CrcCover(Enum):
9
+ """The byte span covered by the CRC-8 of a packet."""
10
+
11
+ HEADER = "magic+type+frame"
12
+ """Magic, packet type byte and the raw frame (the default)."""
13
+
14
+ FRAME = "frame"
15
+ """The raw frame only."""
cantcp/decoder.py ADDED
@@ -0,0 +1,138 @@
1
+ """The Decoder object: reading framed CAN frames from a binary stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Iterator
7
+ from typing import BinaryIO
8
+
9
+ from cantcp.crc_cover import CrcCover
10
+ from cantcp.errors import CantcpError
11
+ from cantcp.frame import Frame
12
+ from cantcp.parser import _FD_PACKET_LEN, Parser
13
+ from cantcp.policy import BadFramePolicy
14
+ from cantcp.stats import Stats
15
+
16
+
17
+ class Decoder:
18
+ """Read framed CAN packets from a binary stream.
19
+
20
+ [magic 2 bytes][type 1 byte][struct can_frame 16 | canfd_frame 72][CRC-8 1]
21
+
22
+ Garbage and packets rejected by the CRC are dropped, stream counters are
23
+ collected and every call returns one raw frame. A decoder is not safe for
24
+ concurrent use: use one decoder per stream.
25
+
26
+ >>> import io
27
+ >>> raw = bytes(16)
28
+ >>> decoder = Decoder(io.BytesIO(Parser().encode(raw)))
29
+ >>> decoder.decode() == raw
30
+ True
31
+ >>> decoder.decode() is None
32
+ True
33
+
34
+ Iteration yields parsed :class:`cantcp.Frame` objects:
35
+
36
+ >>> import io
37
+ >>> stream = io.BytesIO(Parser().encode(bytes(16)))
38
+ >>> print(next(iter(Decoder(stream))))
39
+ Frame{Type:CAN, ID:0x0, Flags:none, DLC:0, Data:}
40
+ """
41
+
42
+ __slots__ = ("_buffer", "_failure", "_parser", "_reader")
43
+
44
+ def __init__(
45
+ self,
46
+ reader: BinaryIO,
47
+ *,
48
+ magic: bytes = b"\xc3\x3c",
49
+ crc_poly: int = 0x07,
50
+ crc_cover: CrcCover = CrcCover.HEADER,
51
+ bad_frame_policy: BadFramePolicy = BadFramePolicy.SKIP,
52
+ logger: logging.Logger | None = None,
53
+ log_level: int = logging.INFO,
54
+ ) -> None:
55
+ """Create a decoder reading from ``reader``.
56
+
57
+ Options configure the internal parser exactly like
58
+ :class:`cantcp.Parser`. The reader is owned by the caller: the decoder
59
+ never closes it.
60
+ """
61
+ self._reader = reader
62
+ self._parser = Parser(
63
+ magic=magic,
64
+ crc_poly=crc_poly,
65
+ crc_cover=crc_cover,
66
+ bad_frame_policy=bad_frame_policy,
67
+ logger=logger,
68
+ log_level=log_level,
69
+ )
70
+ self._buffer = bytearray()
71
+ self._failure: CantcpError | None = None
72
+
73
+ def decode(self) -> bytes | None:
74
+ """Return the next raw frame or ``None`` at the end of the stream.
75
+
76
+ The returned bytes are independent of the decoder buffer and stay
77
+ valid after the next call. A packet cut off in the middle raises
78
+ :class:`cantcp.TruncatedError`; errors of the underlying reader are
79
+ propagated unchanged. After a terminal error every call raises the
80
+ same error, like a spent scanner.
81
+ """
82
+ return self._next_raw()
83
+
84
+ def decode_frame(self) -> Frame | None:
85
+ """Return the next frame parsed into a :class:`cantcp.Frame`.
86
+
87
+ The parsing is as strict as :meth:`cantcp.Frame.from_raw`: a CAN FD
88
+ length the 4-bit DLC field cannot encode raises
89
+ :class:`cantcp.BadLenError` even though the splitter accepted the
90
+ packet. ``None`` reports the end of the stream.
91
+ """
92
+ raw = self._next_raw()
93
+ if raw is None:
94
+ return None
95
+ return Frame.from_raw(raw)
96
+
97
+ def __iter__(self) -> Iterator[Frame]:
98
+ """Iterate over the frames of the stream."""
99
+ return self
100
+
101
+ def __next__(self) -> Frame:
102
+ """Return the next frame or raise :class:`StopIteration` at the end."""
103
+ frame = self.decode_frame()
104
+ if frame is None:
105
+ raise StopIteration
106
+ return frame
107
+
108
+ @property
109
+ def stats(self) -> Stats:
110
+ """A copy of the stream counters."""
111
+ return self._parser.stats
112
+
113
+ def reset_stats(self) -> None:
114
+ """Zero the stream counters."""
115
+ self._parser.reset_stats()
116
+
117
+ def _next_raw(self) -> bytes | None:
118
+ """Return the next raw frame, reading from the stream as needed."""
119
+ if self._failure is not None:
120
+ raise self._failure
121
+ final = False
122
+ while True:
123
+ try:
124
+ advance, token = self._parser.split(bytes(self._buffer), final)
125
+ except CantcpError as exc:
126
+ self._failure = exc
127
+ raise
128
+ if advance:
129
+ del self._buffer[:advance]
130
+ if token is not None:
131
+ return token
132
+ if final:
133
+ return None
134
+ chunk = self._reader.read(_FD_PACKET_LEN)
135
+ if not chunk:
136
+ final = True
137
+ continue
138
+ self._buffer.extend(chunk)
cantcp/encoder.py ADDED
@@ -0,0 +1,87 @@
1
+ """The Encoder object: writing framed CAN frames to a binary stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import BinaryIO
7
+
8
+ from cantcp.crc_cover import CrcCover
9
+ from cantcp.errors import ShortWriteError
10
+ from cantcp.frame import Frame
11
+ from cantcp.parser import Parser
12
+ from cantcp.policy import BadFramePolicy
13
+
14
+
15
+ class Encoder:
16
+ """Write framed CAN packets to a binary stream.
17
+
18
+ [magic 2 bytes][type 1 byte][struct can_frame 16 | canfd_frame 72][CRC-8 1]
19
+
20
+ The encoder wraps :meth:`cantcp.Parser.encode`: the frame length selects
21
+ the packet type, the CRC-8 and its coverage follow the parser
22
+ configuration, so a packet written by an encoder is always accepted by a
23
+ decoder created with the same options. An encoder is not safe for
24
+ concurrent use: use one encoder per stream.
25
+
26
+ >>> import io
27
+ >>> stream = io.BytesIO()
28
+ >>> Encoder(stream).encode(bytes(16))
29
+ >>> stream.getvalue().hex()
30
+ 'c33c010000000000000000000000000000000064'
31
+ """
32
+
33
+ __slots__ = ("_parser", "_writer")
34
+
35
+ def __init__(
36
+ self,
37
+ writer: BinaryIO,
38
+ *,
39
+ magic: bytes = b"\xc3\x3c",
40
+ crc_poly: int = 0x07,
41
+ crc_cover: CrcCover = CrcCover.HEADER,
42
+ bad_frame_policy: BadFramePolicy = BadFramePolicy.SKIP,
43
+ logger: logging.Logger | None = None,
44
+ log_level: int = logging.INFO,
45
+ ) -> None:
46
+ """Create an encoder writing to ``writer``.
47
+
48
+ Options configure the internal parser exactly like
49
+ :class:`cantcp.Parser`; ``bad_frame_policy``, ``logger`` and
50
+ ``log_level`` are accepted for symmetry with the reader side and do
51
+ not affect writing. The writer is owned by the caller: the encoder
52
+ never closes it.
53
+ """
54
+ self._writer = writer
55
+ self._parser = Parser(
56
+ magic=magic,
57
+ crc_poly=crc_poly,
58
+ crc_cover=crc_cover,
59
+ bad_frame_policy=bad_frame_policy,
60
+ logger=logger,
61
+ log_level=log_level,
62
+ )
63
+
64
+ def encode(self, frame: bytes) -> None:
65
+ """Write one packet carrying the raw ``frame``.
66
+
67
+ Validation errors of :meth:`cantcp.Parser.encode`
68
+ (:class:`cantcp.FrameLenError`, :class:`cantcp.BadDLCError`,
69
+ :class:`cantcp.BadLenError`) leave the stream untouched. A write that
70
+ stored fewer bytes than requested raises
71
+ :class:`cantcp.ShortWriteError`; a write error is propagated unchanged
72
+ and leaves the stream in an unknown state.
73
+ """
74
+ packet = self._parser.encode(frame)
75
+ written = self._writer.write(packet)
76
+ if written is None or written < len(packet):
77
+ raise ShortWriteError()
78
+
79
+ def encode_frame(self, frame: Frame) -> None:
80
+ """Write one packet carrying ``frame``.
81
+
82
+ The frame is marshalled with :meth:`cantcp.Frame.to_raw`; its errors
83
+ (:class:`cantcp.BadTypeError`, :class:`cantcp.BadIDError`,
84
+ :class:`cantcp.BadFlagsError`, :class:`cantcp.BadDLCError`,
85
+ :class:`cantcp.BadLenError`) leave the stream untouched.
86
+ """
87
+ self.encode(frame.to_raw())
cantcp/errors.py ADDED
@@ -0,0 +1,85 @@
1
+ """Errors raised by the cantcp package.
2
+
3
+ Every protocol error is a subclass of :class:`CantcpError` with a static
4
+ message; the texts are identical to the Go implementation of the library, so
5
+ logs of both languages match. Catch a concrete class with ``except`` or the
6
+ base class to handle all of them:
7
+
8
+ from cantcp import BadDLCError, CantcpError
9
+
10
+ try:
11
+ frame.to_raw()
12
+ except BadDLCError as exc:
13
+ print(exc) # cantcp: can_dlc > 8
14
+ except CantcpError:
15
+ ... # any other protocol error
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+
21
+ class CantcpError(Exception):
22
+ """Base class for all errors raised by the cantcp package."""
23
+
24
+
25
+ class FrameLenError(CantcpError):
26
+ """A frame is neither 16 (can_frame) nor 72 (canfd_frame) bytes."""
27
+
28
+ def __init__(self) -> None:
29
+ super().__init__("cantcp: frame length is not 16 or 72 bytes")
30
+
31
+
32
+ class BadDLCError(CantcpError):
33
+ """A classic can_frame whose can_dlc is greater than 8."""
34
+
35
+ def __init__(self) -> None:
36
+ super().__init__("cantcp: can_dlc > 8")
37
+
38
+
39
+ class BadLenError(CantcpError):
40
+ """An invalid CAN FD length: greater than 64 or not encodable in DLC."""
41
+
42
+ def __init__(self) -> None:
43
+ super().__init__("cantcp: invalid canfd length")
44
+
45
+
46
+ class BadTypeError(CantcpError):
47
+ """An unknown frame type: not classic CAN and not CAN FD."""
48
+
49
+ def __init__(self) -> None:
50
+ super().__init__("cantcp: unknown frame type")
51
+
52
+
53
+ class BadFlagsError(CantcpError):
54
+ """Flags that are not valid for the frame type."""
55
+
56
+ def __init__(self) -> None:
57
+ super().__init__("cantcp: flags are not valid for the frame type")
58
+
59
+
60
+ class BadIDError(CantcpError):
61
+ """An identifier that does not fit the addressing mode."""
62
+
63
+ def __init__(self) -> None:
64
+ super().__init__("cantcp: identifier does not fit the addressing mode")
65
+
66
+
67
+ class ReservedError(CantcpError):
68
+ """Non-zero reserved or padding bytes of a frame."""
69
+
70
+ def __init__(self) -> None:
71
+ super().__init__("cantcp: reserved bytes are not zero")
72
+
73
+
74
+ class TruncatedError(CantcpError):
75
+ """An incomplete packet at the end of the stream."""
76
+
77
+ def __init__(self) -> None:
78
+ super().__init__("cantcp: stream truncated in the middle of a packet")
79
+
80
+
81
+ class ShortWriteError(CantcpError):
82
+ """A write wrote fewer bytes than requested."""
83
+
84
+ def __init__(self) -> None:
85
+ super().__init__("cantcp: short write")
cantcp/flags.py ADDED
@@ -0,0 +1,31 @@
1
+ """The Flag bits of a Frame."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntFlag
6
+
7
+
8
+ class Flag(IntFlag):
9
+ """Flag bits of a Frame, combined with bitwise OR.
10
+
11
+ ``EFF``, ``RTR`` and ``ERR`` come from the CAN identifier word; ``BRS``
12
+ and ``ESI`` come from the CAN FD flags byte. The constants are the
13
+ argument of :meth:`cantcp.Frame.set_flags`:
14
+
15
+ frame.set_flags(Flag.EFF | Flag.RTR)
16
+ """
17
+
18
+ EFF = 1 << 0
19
+ """Extended (29-bit) identifier."""
20
+
21
+ RTR = 1 << 1
22
+ """Remote transmission request (classic CAN only)."""
23
+
24
+ ERR = 1 << 2
25
+ """Error frame (carries no addressing mode)."""
26
+
27
+ BRS = 1 << 3
28
+ """CAN FD bit rate switch."""
29
+
30
+ ESI = 1 << 4
31
+ """CAN FD error state indicator."""