python-can-hub 0.2.2__py3-none-win_amd64.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.
canhub/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """python-can-hub: native python-can backend for can-hub.
2
+
3
+ The native library loads lazily: importing the package (e.g. for the pure
4
+ python fingerprint helper) must work without libcanhub present.
5
+ """
6
+
7
+ from .fingerprint import identity_fingerprint
8
+
9
+ __all__ = ["CanHubBus", "identity_fingerprint"]
10
+
11
+
12
+ def __getattr__(name):
13
+ if name == "CanHubBus":
14
+ from .bus import CanHubBus
15
+ return CanHubBus
16
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
canhub/__main__.py ADDED
@@ -0,0 +1,18 @@
1
+ """usage: python3 -m canhub fingerprint <certificate.pem>"""
2
+
3
+ import sys
4
+
5
+ from .fingerprint import identity_fingerprint
6
+
7
+
8
+ def main() -> int:
9
+ if len(sys.argv) != 3 or sys.argv[1] != "fingerprint":
10
+ print(__doc__, file=sys.stderr)
11
+ return 1
12
+
13
+ print(identity_fingerprint(sys.argv[2]))
14
+ return 0
15
+
16
+
17
+ if __name__ == "__main__":
18
+ sys.exit(main())
canhub/_native.py ADDED
@@ -0,0 +1,128 @@
1
+ """ctypes bindings over the libcanhub C ABI (include/canhub.h, version 1).
2
+
3
+ The shared library is resolved in order: the CANHUB_LIBRARY environment
4
+ variable, the copy bundled inside this package, the system library path.
5
+ """
6
+
7
+ import ctypes
8
+ import os
9
+ import sys
10
+ from ctypes import POINTER, Structure, c_char, c_char_p, c_int32, c_size_t, c_uint8, c_uint32, c_uint64, c_void_p
11
+
12
+ FRAME_PAYLOAD_MAX = 64
13
+ AGENT_NAME_MAX = 128
14
+ INTERFACE_NAME_MAX = 16
15
+ FILTERS_MAX = 16
16
+
17
+ CAN_ID_MASK = 0x1FFFFFFF
18
+ CAN_ID_FLAG_ERR = 1 << 29
19
+ CAN_ID_FLAG_RTR = 1 << 30
20
+ CAN_ID_FLAG_EFF = 1 << 31
21
+
22
+ FRAME_FLAG_FD = 1 << 0
23
+ FRAME_FLAG_BRS = 1 << 1
24
+
25
+ OPEN_FLAG_NO_ECHO = 1 << 0
26
+ OPEN_FLAG_WRITE = 1 << 1
27
+
28
+ OK = 0
29
+ RECEIVED = 1
30
+ ERR_TIMEOUT = -1
31
+ ERR_DISCONNECTED = -2
32
+ ERR_NOT_FOUND = -3
33
+ ERR_OPEN_REJECTED = -4
34
+ ERR_WRITE_DENIED = -5
35
+ ERR_READ_DENIED = -6
36
+ ERR_ARGUMENT = -7
37
+ ERR_STATE = -8
38
+ ERR_TRANSPORT = -9
39
+ ERR_HUB = -10
40
+
41
+
42
+ class CanHubFrame(Structure):
43
+ _fields_ = [
44
+ ("timestamp_us", c_uint64),
45
+ ("can_id", c_uint32),
46
+ ("flags", c_uint8),
47
+ ("length", c_uint8),
48
+ ("reserved", c_uint8 * 2),
49
+ ("payload", c_uint8 * FRAME_PAYLOAD_MAX),
50
+ ]
51
+
52
+
53
+ class CanHubInterfaceInfo(Structure):
54
+ _fields_ = [
55
+ ("interface_id", c_uint32),
56
+ ("agent", c_char * AGENT_NAME_MAX),
57
+ ("interface_name", c_char * INTERFACE_NAME_MAX),
58
+ ]
59
+
60
+
61
+ class CanHubFilter(Structure):
62
+ _fields_ = [
63
+ ("can_id", c_uint32),
64
+ ("can_mask", c_uint32),
65
+ ]
66
+
67
+
68
+ class CanHubConnectConfig(Structure):
69
+ _fields_ = [
70
+ ("struct_size", c_uint32),
71
+ ("url", c_char_p),
72
+ ("state_directory", c_char_p),
73
+ ("certificate_path", c_char_p),
74
+ ("key_path", c_char_p),
75
+ ("hub_fingerprint", c_char_p),
76
+ ("connect_timeout_ms", c_int32),
77
+ ]
78
+
79
+
80
+ def _load_library():
81
+ bundled_name = "libcanhub.dll" if sys.platform == "win32" else "libcanhub.so"
82
+
83
+ override = os.environ.get("CANHUB_LIBRARY")
84
+ if override:
85
+ return ctypes.CDLL(override)
86
+
87
+ bundled = os.path.join(os.path.dirname(__file__), bundled_name)
88
+ if os.path.exists(bundled):
89
+ return ctypes.CDLL(bundled)
90
+
91
+ if sys.platform == "win32":
92
+ return ctypes.CDLL("libcanhub.dll")
93
+ return ctypes.CDLL("libcanhub.so.0")
94
+
95
+
96
+ lib = _load_library()
97
+
98
+ lib.canhub_api_version.restype = c_uint32
99
+ lib.canhub_api_version.argtypes = []
100
+
101
+ lib.canhub_connect.restype = c_void_p
102
+ lib.canhub_connect.argtypes = [POINTER(CanHubConnectConfig)]
103
+
104
+ lib.canhub_close.restype = None
105
+ lib.canhub_close.argtypes = [c_void_p]
106
+
107
+ lib.canhub_last_error.restype = c_char_p
108
+ lib.canhub_last_error.argtypes = [c_void_p]
109
+
110
+ lib.canhub_list.restype = c_int32
111
+ lib.canhub_list.argtypes = [c_void_p, POINTER(CanHubInterfaceInfo), c_size_t, c_int32]
112
+
113
+ lib.canhub_open.restype = c_int32
114
+ lib.canhub_open.argtypes = [c_void_p, c_char_p, c_uint32, c_int32]
115
+
116
+ lib.canhub_set_filters.restype = c_int32
117
+ lib.canhub_set_filters.argtypes = [c_void_p, POINTER(CanHubFilter), c_uint8]
118
+
119
+ lib.canhub_recv.restype = c_int32
120
+ lib.canhub_recv.argtypes = [c_void_p, POINTER(CanHubFrame), c_int32]
121
+
122
+ lib.canhub_send.restype = c_int32
123
+ lib.canhub_send.argtypes = [c_void_p, POINTER(CanHubFrame)]
124
+
125
+
126
+ def last_error(session):
127
+ detail = lib.canhub_last_error(session)
128
+ return detail.decode("utf-8", errors="replace") if detail else "unknown error"
canhub/bus.py ADDED
@@ -0,0 +1,166 @@
1
+ """python-can backend over libcanhub: can.Bus(interface="canhub", ...)."""
2
+
3
+ import ctypes
4
+ from typing import Optional, Tuple
5
+
6
+ from can import BusABC, CanInitializationError, CanOperationError, Message
7
+
8
+ from . import _native as native
9
+
10
+ DEFAULT_TIMEOUT_MS = 5000
11
+ FINGERPRINT_HEX_LENGTH = 64
12
+
13
+
14
+ def _is_fingerprint(text: str) -> bool:
15
+ if len(text) != FINGERPRINT_HEX_LENGTH:
16
+ return False
17
+ return all(character in "0123456789abcdef" for character in text.lower())
18
+
19
+
20
+ class CanHubBus(BusABC):
21
+ """One hub interface as a python-can bus.
22
+
23
+ :param channel: namespaced interface name ``agent/iface`` or numeric id.
24
+ :param url: hub url (``quic://host:port``, ``tls://``, ``tcp://``,
25
+ ``unix:///path``); ``None`` connects to the local hub unix socket.
26
+ :param identity_cert: path to the client certificate (PEM). Together
27
+ with ``identity_key`` it injects an explicit identity (the
28
+ fingerprint the hub ACLs refer to) instead of the state dir one.
29
+ :param identity_key: path to the client private key (PEM).
30
+ :param hub_fingerprint: expected hub fingerprint (64 hex). When given,
31
+ the connection is rejected unless the hub presents exactly this
32
+ certificate — no TOFU, no pin store on disk.
33
+ :param state_dir: directory holding the client TLS identity and the
34
+ TOFU pin store (tls/quic only); ``None`` uses the can-hub default.
35
+ :param receive_own_messages: also receive the frames this bus sends.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ channel: str,
41
+ url: Optional[str] = None,
42
+ identity_cert: Optional[str] = None,
43
+ identity_key: Optional[str] = None,
44
+ hub_fingerprint: Optional[str] = None,
45
+ state_dir: Optional[str] = None,
46
+ receive_own_messages: bool = False,
47
+ **kwargs,
48
+ ):
49
+ self._session = None
50
+ self._writable = False
51
+
52
+ if hub_fingerprint is not None:
53
+ hub_fingerprint = str(hub_fingerprint)
54
+ if not _is_fingerprint(hub_fingerprint):
55
+ raise CanInitializationError(
56
+ "hub_fingerprint must be 64 hex characters (it may have been "
57
+ "mangled by python-can config value casting; pass it as a string)"
58
+ )
59
+
60
+ config = native.CanHubConnectConfig()
61
+ config.struct_size = ctypes.sizeof(config)
62
+ config.url = url.encode() if url else None
63
+ config.state_directory = state_dir.encode() if state_dir else None
64
+ config.certificate_path = identity_cert.encode() if identity_cert else None
65
+ config.key_path = identity_key.encode() if identity_key else None
66
+ config.hub_fingerprint = hub_fingerprint.encode() if hub_fingerprint else None
67
+ config.connect_timeout_ms = DEFAULT_TIMEOUT_MS
68
+
69
+ self._session = native.lib.canhub_connect(ctypes.byref(config))
70
+ if not self._session:
71
+ raise CanInitializationError(f"could not connect to {url or 'the local can-hub socket'}")
72
+
73
+ self._open(str(channel), receive_own_messages)
74
+ self.channel_info = f"canhub {channel} via {url or 'unix socket'}"
75
+ super().__init__(channel=channel, **kwargs)
76
+
77
+ def _recv_internal(self, timeout: Optional[float]) -> Tuple[Optional[Message], bool]:
78
+ frame = native.CanHubFrame()
79
+ timeout_ms = -1 if timeout is None else max(0, int(timeout * 1000))
80
+
81
+ result = native.lib.canhub_recv(self._session, ctypes.byref(frame), timeout_ms)
82
+ if result == native.RECEIVED:
83
+ return self._to_message(frame), self._filters_applied
84
+ if result == native.ERR_TIMEOUT:
85
+ return None, self._filters_applied
86
+
87
+ raise CanOperationError(native.last_error(self._session))
88
+
89
+ def send(self, msg: Message, timeout: Optional[float] = None) -> None:
90
+ frame = native.CanHubFrame()
91
+
92
+ if not self._writable:
93
+ raise CanOperationError("bus is read-only (write denied by the hub ACL)")
94
+ if msg.timestamp:
95
+ frame.timestamp_us = int(msg.timestamp * 1_000_000)
96
+ frame.can_id = msg.arbitration_id & native.CAN_ID_MASK
97
+ if msg.is_extended_id:
98
+ frame.can_id |= native.CAN_ID_FLAG_EFF
99
+ if msg.is_remote_frame:
100
+ frame.can_id |= native.CAN_ID_FLAG_RTR
101
+ if msg.is_error_frame:
102
+ frame.can_id |= native.CAN_ID_FLAG_ERR
103
+ if msg.is_fd:
104
+ frame.flags |= native.FRAME_FLAG_FD
105
+ if msg.bitrate_switch:
106
+ frame.flags |= native.FRAME_FLAG_BRS
107
+ data = bytes(msg.data or b"")
108
+ frame.length = len(data)
109
+ frame.payload[: len(data)] = data
110
+
111
+ result = native.lib.canhub_send(self._session, ctypes.byref(frame))
112
+ if result != native.OK:
113
+ raise CanOperationError(native.last_error(self._session))
114
+
115
+ def shutdown(self) -> None:
116
+ super().shutdown()
117
+ if self._session:
118
+ native.lib.canhub_close(self._session)
119
+ self._session = None
120
+
121
+ @property
122
+ def _filters_applied(self) -> bool:
123
+ return getattr(self, "_hardware_filtered", False)
124
+
125
+ def _apply_filters(self, filters) -> None:
126
+ if not filters or len(filters) > native.FILTERS_MAX:
127
+ self._hardware_filtered = False
128
+ return
129
+
130
+ native_filters = (native.CanHubFilter * len(filters))()
131
+ for slot, can_filter in zip(native_filters, filters):
132
+ slot.can_id = can_filter["can_id"]
133
+ slot.can_mask = can_filter["can_mask"]
134
+ result = native.lib.canhub_set_filters(self._session, native_filters, len(filters))
135
+ self._hardware_filtered = result == native.OK
136
+
137
+ def _open(self, channel: str, receive_own_messages: bool) -> None:
138
+ flags = native.OPEN_FLAG_WRITE
139
+ if not receive_own_messages:
140
+ flags |= native.OPEN_FLAG_NO_ECHO
141
+
142
+ result = native.lib.canhub_open(self._session, channel.encode(), flags, DEFAULT_TIMEOUT_MS)
143
+ if result == native.ERR_WRITE_DENIED:
144
+ result = native.lib.canhub_open(self._session, channel.encode(), flags & ~native.OPEN_FLAG_WRITE, DEFAULT_TIMEOUT_MS)
145
+ else:
146
+ self._writable = result == native.OK
147
+
148
+ if result != native.OK:
149
+ detail = native.last_error(self._session)
150
+ native.lib.canhub_close(self._session)
151
+ self._session = None
152
+ raise CanInitializationError(f"could not open {channel}: {detail}")
153
+
154
+ @staticmethod
155
+ def _to_message(frame: native.CanHubFrame) -> Message:
156
+ return Message(
157
+ timestamp=frame.timestamp_us / 1_000_000,
158
+ arbitration_id=frame.can_id & native.CAN_ID_MASK,
159
+ is_extended_id=bool(frame.can_id & native.CAN_ID_FLAG_EFF),
160
+ is_remote_frame=bool(frame.can_id & native.CAN_ID_FLAG_RTR),
161
+ is_error_frame=bool(frame.can_id & native.CAN_ID_FLAG_ERR),
162
+ is_fd=bool(frame.flags & native.FRAME_FLAG_FD),
163
+ bitrate_switch=bool(frame.flags & native.FRAME_FLAG_BRS),
164
+ dlc=frame.length,
165
+ data=bytes(frame.payload[: frame.length]),
166
+ )
canhub/fingerprint.py ADDED
@@ -0,0 +1,23 @@
1
+ """can-hub fingerprints without native code: sha256 over the certificate DER."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import re
6
+
7
+ _PEM_BODY = re.compile(
8
+ r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----",
9
+ re.DOTALL,
10
+ )
11
+
12
+
13
+ def identity_fingerprint(certificate_path: str) -> str:
14
+ """Fingerprint of a PEM certificate: what the hub pins and the ACLs key on."""
15
+ with open(certificate_path, "r", encoding="ascii") as certificate_file:
16
+ pem = certificate_file.read()
17
+
18
+ match = _PEM_BODY.search(pem)
19
+ if match is None:
20
+ raise ValueError(f"{certificate_path} does not contain a PEM certificate")
21
+
22
+ der = base64.b64decode(match.group(1))
23
+ return hashlib.sha256(der).hexdigest()
canhub/libcanhub.dll ADDED
Binary file
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.1
2
+ Name: python-can-hub
3
+ Version: 0.2.2
4
+ Summary: python-can backend for can-hub: remote CAN interfaces over unix/tcp/tls/quic
5
+ License: AGPL-3.0-only
6
+ Project-URL: Homepage, https://github.com/can-hub-io/can-hub
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: python-can >=4.0
10
+
11
+ # python-can-hub
12
+
13
+ Native [python-can](https://python-can.readthedocs.io) backend for
14
+ [can-hub](https://github.com/can-hub-io/can-hub): consume remote CAN interfaces
15
+ exported by can-hub agents, directly over the binary protocol — unix socket,
16
+ plain TCP, TLS or QUIC, with mTLS identity and TOFU pinning on the encrypted
17
+ transports. No bridge process in between.
18
+
19
+ ```python
20
+ import can
21
+
22
+ bus = can.Bus(
23
+ interface="canhub",
24
+ channel="truck42/can0",
25
+ url="quic://hub.example.com:7227",
26
+ )
27
+ bus.send(can.Message(arbitration_id=0x123, data=b"\xDE\xAD\xBE\xEF"))
28
+ for message in bus:
29
+ print(message)
30
+ ```
31
+
32
+ - `channel`: namespaced interface `agent/iface` (or the numeric id from
33
+ `can-hub-client list`).
34
+ - `url`: omit to connect to the local hub unix socket.
35
+ - `state_dir`: client TLS identity + pin store location (tls/quic).
36
+ - `receive_own_messages`: standard python-can echo semantics.
37
+
38
+ Write access follows the hub client ACLs: if the ACL grants read-only, the
39
+ bus opens read-only and `send()` raises.
40
+
41
+ The wheel bundles `libcanhub.so` with the TLS/QUIC stack linked in
42
+ statically; the only runtime dependency is glibc.
43
+
44
+ ## Building from source
45
+
46
+ ```sh
47
+ ./scripts/build-python-wheel.sh # host arch, glibc-tagged (local dev)
48
+ pip install python/dist/*.whl
49
+ ```
50
+
51
+ Distributable manylinux wheels are built per architecture in a manylinux
52
+ container and repaired by auditwheel (needs docker, plus QEMU binfmt for the
53
+ cross arches):
54
+
55
+ ```sh
56
+ ./scripts/build-python-wheel.sh x86_64 # python/dist/x86_64/*.whl
57
+ ./scripts/build-python-wheel.sh aarch64
58
+ ./scripts/build-python-wheel.sh armv7l
59
+ ```
60
+
61
+ The release workflow builds all three and publishes them to PyPI on a `v*`
62
+ tag.
@@ -0,0 +1,11 @@
1
+ canhub/__init__.py,sha256=yC7SFSz4vUDFgve7gehAaOSqZ1Iv1Kbv1A4c5nj_XcM,485
2
+ canhub/__main__.py,sha256=WM0VMTSjzySENh7zkbTT4WpLnqyS_gsZBgJ4f05qNHQ,366
3
+ canhub/_native.py,sha256=4MHqnxR86NfMN_4jrp5rg60MR1hfhD7p-zXozSoAEAg,3295
4
+ canhub/bus.py,sha256=dLwIGXdGplCyG_vh0Rb9DmM5kUjGCMyV_OlMU10q4x4,7060
5
+ canhub/fingerprint.py,sha256=N38dP3_odFYn6821XViWG6aQKUPhxJHDY8xE8uW8zNo,711
6
+ canhub/libcanhub.dll,sha256=BFcvsPyRdRgD2wiGAAlHZEDRXTqHQlHwjlTkDaxu19k,6396928
7
+ python_can_hub-0.2.2.dist-info/METADATA,sha256=MaxFh9oRfrcQ60f7_UfbYdbz6Ng3RarPS0Om8fDaWVs,1998
8
+ python_can_hub-0.2.2.dist-info/WHEEL,sha256=KTdQDMVZqs2eeRhOpF4kInPW2OgLgRWl7KhVZQueA2o,99
9
+ python_can_hub-0.2.2.dist-info/entry_points.txt,sha256=ZfPo9cCR4xH9BjYRCi_nOcw_48SN6vqboV0B2y8HcjU,46
10
+ python_can_hub-0.2.2.dist-info/top_level.txt,sha256=nPyUT2Mw-NsKrJhpH-4EUcBBqz7GOSwfxnGkFEILnKk,7
11
+ python_can_hub-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.42.0)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1,2 @@
1
+ [can.interface]
2
+ canhub = canhub.bus:CanHubBus
@@ -0,0 +1 @@
1
+ canhub