This release is a pre-release and may not be stable for production use.
zelos-can
Rust-first CAN codec for the Zelos ecosystem. DBC-driven signal decode + trace emission at wire speed, with optional python-can–compatible bus implementations for live SocketCAN and in-memory testing.
Install
pip install zelos-can
# python-can compat layer and runtime (optional; only needed for
# can.Bus(interface="zelos-socketcan", ...) or the python-can runtime below):
pip install 'zelos-can[python-can]'
Prebuilt wheels ship for Linux, macOS, and Windows. Headless decode
(CanDecoder, VirtualBus, CanCodec) works on all three; the live
zelos-socketcan bus is Linux-only (see the feature matrix).
Three ways to use it
1. Drop-in python-can bus (live capture on Linux)
import can
bus = can.Bus(interface="zelos-socketcan", channel="can0")
# Kernel-level filters, SO_RCVBUF tuning, SO_TIMESTAMPNS, reading on
# through link drops and unplugs — all handled in Rust.
Compatible with can.Notifier, bus.send_periodic, bus.state, and the
bus.socket escape hatch. Drop-in for interface="socketcan".
zelos-virtual (in-memory) and zelos-ssh-socketcan (a remote Linux bus over
ssh + the edge's candump/cansend, any OS, channel [user@]host:can0) are
registered the same way. Over ssh the edge hears this bus's own transmits like
any frame, so they are always received.
2. Bring your own frame source (this is the main pattern)
CanDecoder is a thread-safe handle that takes raw frames and emits decoded
signals into the Zelos trace pipeline. Where frames come from is your
problem — anything that can produce arbitration_id + data + timestamp works.
The DBC is optional. With no database_file / database_bytes the decoder
skips signal decode and just traces the raw frames (log_raw_frames defaults on
in that case) — handy for capturing a bus you don't have a DBC for yet:
from zelos_can import CanDecoder
raw = CanDecoder(source_name="can") # no DBC → raw frames only
raw.decode_frame(arbitration_id=0x123, data=b"\x01\x02")
from zelos_can import CanDecoder
decoder = CanDecoder(database_file="vehicle.dbc", source_name="can")
# From python-can (PEAK/Vector/Kvaser/socketcan/whatever):
for msg in my_bus:
decoder.decode_message(msg)
# From a network gateway (cannelloni/SLCAN/your protocol):
decoder.decode_frame(
arbitration_id=0x123,
data=b"\x01\x02",
timestamp_ns=time.time_ns(),
is_extended=False,
is_fd=False,
)
# From a .asc/.blf/.trc/.mf4 file via python-can readers:
for msg in can.BLFReader("capture.blf"):
decoder.decode_message(msg)
decode_frame and decode_message release the GIL for the duration of the
Rust decode + trace emit — the caller thread stays free.
Complete examples in examples/:
file_replay.py— .asc/.blf/.trc/.mf4/.log/.csv → .trzpython_can_forward.py— any python-can interface → live trace + .trzudp_source.py— skeleton for a network-CAN gatewayvirtual.py— in-memory VirtualBus + native Rust codecsocketcan.py— native Rust SocketCAN codec (Linux)
3. Native Rust capture pipeline (fastest)
For the tightest hot path (no Python callbacks per frame), use the native
CanCodec with either a VirtualBus or a SocketCAN channel:
from zelos_can import CanCodec, VirtualBus
bus = VirtualBus(channel="sim")
codec = CanCodec(database_file="vehicle.dbc", source_name="sim", bus=bus)
# codec reads frames in Rust; Python never sees individual frames.
Same applies to real hardware via channel="can0" (Linux SocketCAN):
codec = CanCodec(
database_file="vehicle.dbc",
source_name="can",
channel="can0",
log_raw_frames=True,
timestamp_mode="hardware",
)
python-can runtime
The bus layer the Zelos CAN, XCP and UDS extensions share: config to a running
bus on any python-can interface, DBC decode and transmit, ssh-bridged
SocketCAN, offline log conversion. Needs the python-can extra; importing a
module without it raises an ImportError naming the extra. zelos_can.bus
imports without it; open_python_can_bus needs it.
| module | what |
|---|---|
zelos_can.bus |
prepare_bus_config, open_python_can_bus, BUS_DEFAULTS, BusConfigError, discovery.list_interfaces |
zelos_can.codec |
CanCodec: one supervised bus with DBC decode (cantools), trace emit, TX and periodics, bus health and recovery; zelos-socketcan and zelos-ssh-socketcan run on the Rust CanCodec, any other python-can interface on python-can |
zelos_can.ssh_socketcan |
SshTransport: a remote SocketCAN bus over ssh + candump/cansend |
zelos_can.converter |
convert_can_trace: .asc/.blf/.trc/.log/.csv/.mf4 to .trz |
zelos_can.demo |
EV simulator and its DBC (interface: demo) |
zelos_can/bus/interfaces.schema.json |
per-interface config fields, keyed by the interface labels an extension offers (SocketCAN (Zelos), PCAN, ...), for its config.schema.json; the extension maps labels to python-can names |
from pathlib import Path
from zelos_can.bus import open_python_can_bus, prepare_bus_config
entry = {"name": "pt", "interface": "pcan", "channel": "PCAN_USBBUS1", "bitrate": 500_000}
config = prepare_bus_config(entry, Path()) # 2nd arg: the DBC an `interface: demo` bus uses
bus = open_python_can_bus(config, "pt") # python-can BusABC, init retried 3 times
interface is a python-can interface name, passed as is: zelos-socketcan,
zelos-ssh-socketcan (with the remote_host / ssh_* fields), socketcan,
pcan, ...
Trace pipeline
Every decode path funnels through zelos-sdk's TraceNamespace. If
zelos_sdk.init() has been called the decoded signals stream live to the
Zelos agent over gRPC; wrap your code in a zelos_sdk.TraceWriter(...)
context to also record to a .trz file. Both work simultaneously.
Feature matrix
| feature | zelos-socketcan |
zelos-virtual |
CanDecoder |
|---|---|---|---|
python-can can.Bus compat |
✓ | ✓ | n/a |
kernel filter (CAN_RAW_FILTER) |
✓ | n/a | n/a |
bus.state / bus.socket |
✓ | n/a | n/a |
error frames (is_error_frame) |
✓ (on by default) | n/a | n/a |
CAN FD bitrate_switch / error_state_indicator |
✓ | n/a | n/a |
local_loopback / receive_own_messages |
✓ | n/a | n/a |
| native periodic transmit | kernel BCM (send_periodic) + stop_all_periodic_tasks |
Rust timer | n/a |
| DBC decode + trace in Rust | via Notifier + CanDecoder |
via CanCodec or CanDecoder |
always |
| Linux-only | yes | no | no |
Transmitted frames are traced like any other when the bus is opened with
receive_own_messages=True — TX frames echo back through the RX path (with
is_rx=False) and flow into the decode/trace pipeline, so a recording
captures exactly what hit the wire (including kernel BCM periodics).
Decoder semantics — divergences from cantools
zelos-can decodes per the DBC spec. For well-formed inputs we match
cantools exactly.
SIG_VALTYPE_ interpretation
Both decoders treat the : 1 / : 2 marker as advisory and pick the IEEE
754 binary format from the signal length:
| length | format |
|---|---|
| 16 | binary16 (half precision) |
| 32 | binary32 (single precision) |
| 64 | binary64 (double precision) |
| anything else | no canonical IEEE format → decode-time error |
For lengths that aren't a real IEEE binary width (e.g. SIG_VALTYPE_:1 + length=10):
cantoolsraises at decode time (expected float size of 16, 32, or 64 bits).zelos-canpreservesis_floatso the schema field stays typed as float, butdecode_signalreturnsNoneper-frame and thedecode_errorsmetric ticks. A parse-time warning surfaces the malformed DBC up front (cantools surfaces nothing at load time).
Storage typing for unsigned signals with negative offset
For an unsigned raw with a DBC offset that drives physical values negative
(e.g. (1, -1000) on a u16):
cantoolsreturns Pythonint(unbounded — sign is implicit).zelos-canpromotes the Arrow schema type to the signed sibling (Int{16,32,64}). The typed cache must store a fixed-width Arrow type; the unsigned variant would saturatef64 → uNcasts of negative physicals to zero, silently zeroing valid frames.
Everything else
Integer signals, factor/offset on integer raws, big/little endianness,
multiplexing, value tables, and well-formed IEEE float widths all decode
identically to cantools.
A2L reader (zelos_can.a2l)
Reads an ASAM MCD-2 MC (ASAP2) file into one catalog dict for XCP measurement.
Not imported by import zelos_can.
from zelos_can import a2l
cat = a2l.load("ecu.a2l") # strict=False by default
for m in cat["measurements"]:
print(m["name"], m["address"], m["datatype"], m["conversion"]["kind"])
print(cat["transports"], cat["events"], cat["warnings"])
| Key | Content |
|---|---|
measurements |
name, address, address_extension, datatype, byte_order, bit_mask, dims, conversion, unit, lower, upper, events, groups |
events |
XCP event channels: name, channel, cycle, cycle_unit (code as stated), priority, max_daq_lists, consistency (DAQ, EVENT, ODT, NONE or None) |
transports |
XCP on UDP / TCP (host, port) and CAN (ids, bitrate, CAN FD), with max_cto / max_dto |
daq, epk |
DAQ type, limits and timestamp; EPK string and address |
warnings |
file:line: ... for parser warnings and every skipped entry |
The full shape is in the zelos_can.a2l docstring.
- No silent misreads. Parse faults, integers out of range for their field
(a2lfile narrows hex literals otherwise) and duplicate names after structure
flattening raise
a2l.A2lError(aValueError) withfileandline. - Skipped, with a reason in
warnings: calibration components, pointers,MSB_FIRST_MSW_LAST/MSB_LAST_MSW_FIRST,BIT_OPERATION, instanceOVERWRITE, missing conversions, transports other than UDP/TCP/CAN. - Structures:
INSTANCEs flatten toinst.member/inst[i].member. - Conversions: IDENTICAL, LINEAR, RAT_FUNC and TAB_VERB (incl. COMPU_VTAB_RANGE) in full; TAB_INTP, TAB_NOINTP and FORM carried, not evaluated.
- Missing
/include: dropped with a warning;strict=Trueraises instead. XCPlite files reference anXCP_104.amlthat is rarely shipped alongside.
J1939 (zelos_can.j1939)
Decoding is the j1939 keyword of CanCodec / CanDecoder ("auto" by default:
messages the DBC marks VFrameFormat = J1939PG, or every extended one under a
ProtocolType = "J1939" database attribute; "on": every 29-bit message; "off").
The module is the id arithmetic a sender needs, and the reference for what a PG
means to the decoder. Not imported by import zelos_can.
from zelos_can import j1939
j1939.pgn(0x0CF00401) # 61444, EEC1 from sender 0x01
j1939.can_id(0xF004, source=0x21, priority=3) # 0x0CF00421
j1939.can_id(0xEF00, source=0x21, destination=0x0B) # PDU1: the destination is in PS
can_id, data = j1939.request(0xFECA, source=0x21) # Request DM1 from everyone
| Decoder behavior | |
|---|---|
| Matching | by PGN, whatever the priority and sender; most specific definition first (own address, sender, source 0xFE, then the one shared layout) |
| Tables | one per sender (and destination, for PDU1): 0cf00400_EEC1, 0cf00401_EEC1 |
| Transport protocol | BAM and RTS/CTS transfers rebuilt and decoded, stamped at the last packet; tp_* in Metrics |
| Validity | raw values J1939-71 reserves (0xFB.. and up per byte width) decode to null unless the DBC's range or a VAL_ takes them in |
CANopen (zelos_can.canopen)
Decoding is the canopen / canopen_nodes keywords of CanCodec / CanDecoder
("auto" by default: on with at least one node; "on": protocol decode for every
node-id; "off"). The module holds the EDS/DCF reader, the id arithmetic and NMT
frame a master sends, and the reference for the decode semantics. Not imported by import zelos_can.
from zelos_can import CanDecoder, canopen
decoder = CanDecoder(canopen_nodes=[{"node_id": 0x20, "name": "pdu", "file": "pdu.eds"}])
decoder.canopen_nodes() # [{node_id, name, configured, state, heartbeat_lost, ...}]
decoder.canopen_warnings() # file, construction and learning warnings, each once
cob, data = canopen.nmt_frame(canopen.NMT_START, 0) # start every node
| Decoder behavior | |
|---|---|
| Nodes | {node_id, name?, file?}; file (EDS/DCF) adds PDOs, typed SDO and learning; node_id may come from a DCF; an unreadable file is a ValueError |
| Claimed | NMT, SYNC, TIME, EMCY, SDO, heartbeat, LSS by the predefined set for every node-id; a configured node's own COB-IDs and PDOs. A DBC message wins on an unconfigured node's COB-ID, collides (error) on a configured node's |
| Tables | 0720_pdu_Heartbeat, 05a0_pdu_SDO, 00a0_pdu_EMCY, 01a0_pdu_TPDO1, 0000_NMT, 0080_SYNC, 0100_TIME, 07e5_LSS |
| PDO columns | mapped object names, <Parent>_<Sub> for record subs, as identifiers; typed by the OD (signed, float, strings up to the first NUL); a short frame decodes nothing (canopen_pdo_short) |
| Learning | confirmed SDO downloads to 0x1400-0x1BFF, 0x1017, 0x1005/0x1012/0x1014 update the node; a remap after the table was registered (a row went out, or emit_schemas_on_init) registers ..._TPDO1_2 (canopen_pdo_remapped); concise DCF (0x1F22) not parsed (canopen_pdo_unlearned) |
| Reset | NMT reset node / communication (to the node or all) and its boot-up revert what was learned to the file's values (canopen_pdo_reverted), unless a confirmed 0x1010 sub 1/2 "save" came after |
| SDO rows | one per transfer; value/text typed by the OD, null without it (data hex is the authority); closed by completion, abort, a new initiate (superseded) or flush() (incomplete); no decoder timeout |
| Heartbeat | late = more than 2x the producer time (0x1017) after the previous one, null without it; heartbeat_lost in canopen_nodes() (canopen_heartbeat_lost): offline on each frame's time, live also on a timer |
A live CanCodec is also a client: codec.sdo_read(0x20, 0x1018, 1) /
codec.sdo_write(0x20, 0x6200, 1, b"\x01") return {value, text, data, size, abort_code, abort_text} (a server abort is returned, a timeout raises
TimeoutError after sending abort 0x05040000); codec.nmt("start", 0x20).
| Client | |
|---|---|
| Transfers | expedited and segmented, both directions; no block transfer; one at a time per node (a second call raises RuntimeError); timeout up to 60 s |
| After a timeout | the node's next request waits until timeout has passed since the abort; answers meanwhile are stale (canopen_client_stale) |
| Channel | the node's first SDO channel from its file, else 0x600/0x580 + id; an answer for another object or the other direction is ignored (canopen_client_stale) |
| Trace | each transfer is one SDO row on every lane: from the lane's loopback copy where it echoes TX (VirtualBus, ExternalBus(loopback=True), ssh), else when sent; canopen_client_sdo, canopen_client_timeout |
Metrics: canopen_frames, canopen_sdo_completed, canopen_sdo_aborted,
canopen_sdo_resync, canopen_sdo_crc_error, canopen_short_frame,
canopen_heartbeat_late, canopen_heartbeat_lost, canopen_pdo_short,
canopen_pdo_remapped, canopen_pdo_unlearned, canopen_pdo_reverted,
canopen_unknown_node, canopen_client_sdo, canopen_client_timeout,
canopen_client_stale.
EDS/DCF reader
Reads a CiA 306 EDS or DCF into one catalog dict: the object dictionary plus the node's resolved PDOs and communication objects.
from zelos_can import canopen
cat = canopen.load("pdu.eds", node_id=0x20) # strict=False by default
for p in cat["pdos"]:
print(p["kind"], p["n"], hex(p["cob_id"]), [e["name"] for e in p["entries"]])
print(cat["comm"]["sdo"], cat["comm"]["emcy"], cat["warnings"])
| Key | Content |
|---|---|
objects |
{index: {name, object_type, data_type, sub: {subindex: {name, data_type, bits, access, default, value, low, high, pdo_mappable, ...}}}} |
pdos |
TPDO/RPDO n, COB-ID (bit 29 extended, bit 30 RTR), transmission type, timers, mapped entries (index, sub, bits, name, data type, dummy) |
comm |
SDO channels, EMCY, heartbeat COB-ID and period, SYNC, TIME, SYNC counter overflow, identity |
device_info, file_info, commissioning |
[DeviceInfo], [FileInfo], DCF [DeviceComissioning] |
warnings |
file:line: ... for every skipped or ignored entry |
The full shape is in the zelos_can.canopen docstring.
- Node id: the
node_idargument, else the DCF'sNodeID. Resolves$NODEID+n/n+$NODEIDand the predefined COB-IDs; without one they stay"$NODEID+0x..."and PDOcob_idis None. - Values:
ParameterValue(DCF) isvalue,DefaultValueisdefault; resolution usesvaluefirst. Leading-0 octal, two's complement hex for signed types,CompactSubObjwith[XXXXName]/[XXXXValue]. - Skipped, with a reason in
warnings: PDOs with COB-ID bit 31, disabled or MPDO mappings, missing mapping objects or entries; unknown data or object types; unparsable values. Duplicate sections merge (later keys win), warned. - Errors:
canopen.CanopenError(aValueError) withfileandlinefor an unreadable file or a malformed line;strict=Trueraises the first warning. - Encoding: ISO-8859-1 per CiA 306; a UTF-8 BOM is dropped.
Metrics
m = decoder.metrics()
# messages_received / messages_decoded / unknown_messages / decode_errors
# kernel_drops (SocketCAN only, SO_RXQ_OVFL)
# broadcast_overflows (VirtualBus slow-consumer lag)
# reconnections (SocketCAN auto-recover)
Tests against nodes (test/hil)
Tiers are pytest markers; a station file says which ones a host provides and the rest skip.
| tier | frames from | run with |
|---|---|---|
sim |
committed captures replayed on VirtualBus, any OS |
just test (station local) |
vcan |
the same captures replayed on vcan0 |
just test-vcan (station vcan) |
hw, hil |
the bench node on real adapters; hil flashes and resets it under a lock |
just hil [WHEEL] (station bench) |
Bench setup: copy test/hil/stations/bench.example.toml to bench.toml (gitignored) and
fill in the channels, lock file, firmware dir and flash/reset/console commands.
just hil-capture NODE SECONDS records a real capture that replaces the synthetic one.
The pdu node (CANopen node 0x20, test/hil/nodes/pdu.eds) runs on every tier for decode, and
on hil against python-canopen as master for its own behavior (test_pdu_conformance.py).
Benchmarks
Numbers measured on a single apple-silicon core via criterion. Ballpark only —
reproduce with cargo bench.
| bench | throughput |
|---|---|
| decode_frame/dut_status_all_signals (9 sig) | ~217 Msig/s (41 ns) |
| virtual_bus_fanout/subscribers=1 | 21 Melem/s |
| virtual_bus_fanout/subscribers=4 | 38 Melem/s |
| virtual_bus_fanout/subscribers=16 | 50 Melem/s |
At 9 signals per message the decode path is ~24M full DBC-decoded messages
per second in pure Rust, which is well past any realistic CAN bus rate — the
GIL crossing on the Python boundary is what actually sets your ceiling, which
is why the CanDecoder + Notifier (no per-frame GIL re-entry) path
exists.
Broadcast fanout scales sublinearly (16-subscriber case delivers 50M total frames/s vs 21M for one subscriber), so adding listeners costs negligibly in the expected 1-10kHz CAN regime.
Metadata
Release files for zelos-can 0.0.11a4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| zelos_can-0.0.11a4-cp310-abi3-win_amd64.whl | CPython 3.10 | abi3 | Windows x86-64 | Details |
| zelos_can-0.0.11a4-cp310-abi3-manylinux_2_28_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.28+ x86-64 | Details |
| zelos_can-0.0.11a4-cp310-abi3-manylinux_2_28_aarch64.whl | CPython 3.10 | abi3 | Linux glibc 2.28+ ARM64 | Details |
| zelos_can-0.0.11a4-cp310-abi3-macosx_11_0_arm64.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64 | Details |
Total release size: 11.6 MB
Release files / zelos_can-0.0.11a4-cp310-abi3-win_amd64.whl
| Download URL | zelos_can-0.0.11a4-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 2.6 MB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
70691d5639a5ddf3d391c00db3a6ab89a24c3f3b652edafe3dee85b341a228b1
|
|
BLAKE2b-256 checksum How to use checksums |
d6181c7fb24b9026ababf95d22196fbe601484b7ff8cd33a1a72b00df7ea177f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.13
|
Release files / zelos_can-0.0.11a4-cp310-abi3-manylinux_2_28_x86_64.whl
| Download URL | zelos_can-0.0.11a4-cp310-abi3-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 3.5 MB |
| Tags | CPython 3.10 Linux glibc 2.28+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
d43e34eb32a2348605af6ead695d495a6a0c70e4875c26aa1dcd2f2e39f4e453
|
|
BLAKE2b-256 checksum How to use checksums |
3a32b9eb565da0f6aaecb3b5402cb7fd7311150f5029891b8a97fb3b8675ccb2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.13
|
Release files / zelos_can-0.0.11a4-cp310-abi3-manylinux_2_28_aarch64.whl
| Download URL | zelos_can-0.0.11a4-cp310-abi3-manylinux_2_28_aarch64.whl |
|---|---|
| Size | 3.1 MB |
| Tags | CPython 3.10 Linux glibc 2.28+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
e72ce35d720aaa4d280610a904919e06ef92ee8fc4e854ca418d19dba3de24ed
|
|
BLAKE2b-256 checksum How to use checksums |
2daf177f38fba801d2f94abc9961c79cf8b40d88ac7a98609224ef567f9fceb0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.13
|
Release files / zelos_can-0.0.11a4-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | zelos_can-0.0.11a4-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 2.4 MB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
c141a80943b0c9acfab9f8f9cac9dcf98dc76af53c374b15bed61d7510cd49ae
|
|
BLAKE2b-256 checksum How to use checksums |
a4cc0d09d72a4f3f61ad78e687120f284ad75fd5007f5e321f7cebe7bbaf0a33
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.13
|
Security for zelos-can 0.0.11a4
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.