Skip to main content
Pre-release

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")

database_file takes .dbc and .arxml (AUTOSAR 4.x System Template), one or a list merged in order; the .arxml files load together as one system and cluster= picks the CAN cluster when they define several.

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 file via python-can readers (.mf4/.log: zelos_can.LogReader):
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/:

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 on the Rust CanCodec (lanes below): DBC decode, trace emit, TX and periodics, bus health and recovery; j1939_claim / j1939_send* / j1939_request and J1939 periodics through the node (config j1939, j1939_node); with CANopen on (config canopen, canopen_nodes), canopen_sdo_read / canopen_sdo_write (typed by the node's EDS/DCF) / canopen_nmt / canopen_describe / canopen_nodes_status
zelos_can.dbc DbcCatalog: a bus's ordered database list on zelos_can.DbcDatabase; resolve (key, or a name only one definition carries), encode, describe, j1939_node
zelos_can.ssh_socketcan SshTransport: a remote SocketCAN bus over ssh + candump/cansend
zelos_can.python_can_transport PythonCanTransport: any python-can bus as a transport (a reader and a writer thread); adapter_echo, the echo rule below
zelos_can.isotp, zelos_can.doip the diagnostics transports the UDS and OBD-II extensions share (extra diag): ISO-TP on a zelos_can.bus (ids, address, isotp_params, open_bus, permanent; QuietStack + listening: a stack silent between requests, no flow control to other testers; functional_request + responder_stacks: one single frame on 0x7DF / 0x18DB33F1, answers from 0x7E8-0x7EF / 0x18DAF1xx within P2, multi-frame answers reassembled per ECU) and DoIP (identify, connect, Client with a bounded connect, a lossless TCP read and the raw request / response / drain; Connection for udsoncan)
zelos_can.converter convert_can_trace: .asc/.blf/.trc/.log/.candump/.csv/.mf4 to .trz (j1939=True, --j1939: every 29-bit message is a PG; canopen, canopen_nodes, --canopen, --canopen-node ID[:FILE[:NAME]]; obd, `--obd on
zelos_can.obd the built-in J1979 PID catalogue (pids(): per-column type, unit, range and value table, as the <id>_OBD2 schema), the passive decoder (decode(sid, data))
zelos_can.demo EV simulator and its DBC (interface: demo), over zelos-virtual; canopen_node: true adds a simulated CANopen node 0x20 (python-canopen, pdu.eds)
zelos_can/bus/interfaces.schema.json per-interface config fields, keyed by the interface labels an extension offers (SocketCAN, 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, ...

send_timeout (BUS_DEFAULTS, 1.0 s; settable per bus in config_json): how long send(msg) with no timeout waits for TX queue room on zelos-socketcan / zelos-ssh-socketcan before raising; 0 is one attempt. An explicit send(msg, timeout=...) wins. PythonCanTransport sends with no timeout on python-can's own adapters: queue and go, never waiting on the wire.

Every CanCodec bus runs the Rust codec; the lane is what feeds it:

interface lane frames reach the Rust codec from
zelos-socketcan native its own SocketCAN socket (Linux)
zelos-virtual, demo virtual its own endpoint on zelos-can's in-memory bus
zelos-ssh-socketcan headless an ExternalBus fed by SshTransport
any other (socketcan, pcan, kvaser, vector, slcan, gs_usb, virtual, other) headless an ExternalBus fed by PythonCanTransport

A headless transport is disposable: checked every 5 s and rebuilt (backing off to 60 s while rebuilds fail) on a stopped reader or writer, a bus state other than ACTIVE, a bus-off PCAN controller, a replugged socketcan interface or a dead ssh session; the codec and the J1939 claim survive, and the periodics halt for the rebuild and start again on the new transport. While the transport is down, sends, periodic starts and J1939 and CANopen requests refuse with "bus is reconnecting; try again shortly". A failed read (a PCAN receive-queue overrun) counts rx_errors and reading goes on; 50 back to back within 1 s (an unplugged adapter) end the reader. Error frames count error_frames and mark the bus's health, never traced. A send the adapter refuses counts tx_errors and marks the health; the action returned when the frame was queued.

Own frames (receive_own_messages, on by default) are traced once: from the adapter's echo where it gives one, else from the ExternalBus's own.

adapter echoes its own sends
virtual, kvaser, vector, socketcan, any other when receive_own_messages asks. An adapter that ignores the flag traces its own frames zero times (python-can serial, seeedstudio) or twice (Kvaser with single_handle in config_json)
pcan on Windows only (PCAN-Basic for Linux and macOS's PCBUSB refuse the flag)
gs_usb always; dropped with receive_own_messages off
slcan never

Where the adapter does not echo (slcan, PCAN on macOS and Linux) a send is traced as it is queued for the adapter, not at the wire's ACK, and a send the adapter then refuses is traced too (and counted in tx_errors). The same holds for what periodics queue between an adapter failing and the 5 s check noticing: dropped and counted in tx_errors on the rebuild, already traced.

DBC encode and describe (zelos_can.DbcDatabase)

The merged database view behind CanCodec.database / CanDecoder.database, or built alone: DbcDatabase(database_file=[...], j1939="auto", cluster=None).

from zelos_can import DbcDatabase

db = DbcDatabase(database_file=["vehicle.dbc"])
db.list_messages()                    # [{key, name, can_id, is_extended, is_fd, dlc, cycle_time_ms, database}]
db.describe_message("0100_BMS_Status")  # + signals: start_bit, length, byte_order, scale, offset, min, max, unit, value_table, mux
db.encode("BMS_Status", {"soc": 84, "state": "Charging"}, mux=None)  # bytes
db.resolve("BMS_Status")              # the key; a name at several ids refuses
db.nodes(), db.attribute_defaults()   # BU_ attributes, for the J1939 node identity

encode is byte-identical with cantools' encode(scaling=True, padding=False, strict=False) (test/test_encode_parity.py runs every fixture DBC through both): physical to raw by the DBC's factor and offset with ties to even, VAL_ labels accepted, the mux switch taken from the payload or mux, every missing signal named, raw values checked against the bit field only (not [min|max]). One deliberate refusal: a VAL_ label on several raw values raises (cantools sends the last in file order); send the raw value. An ARXML signal whose conversion is not one linear scale, or a frame whose PDU is more than plain signals (container, multiplexed, secured, E2E, CanTp), raises too; describe gives such a signal scale and offset None. A '.' in a long symbol or a mux label is renamed to '_' with one warning: '.' is the trace catalog's path separator. A rename onto a name another signal of the message, or another label of its mux switch, already has refuses the DBC.

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 ✓ receive_own_messages 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). A CanCodec on zelos-virtual reads its own bus's endpoint, so the frames its TX actions and periodics send decode only with receive_own_messages=True (the default); a zelos-virtual bus on its own receives them unless receive_own_messages=False.

CanCodec(recv_own_messages=True) (the default) means the same on every bus: what the codec sends (send, periodics, the J1939 node, the CANopen client) comes back through its RX path and is decoded once; False leaves it out of the trace. SocketCAN sets it on the codec's socket, VirtualBus skips its own sends, and ExternalBus echoes its sends itself unless built with loopback=True (the transport echoes, as ssh's candump does, or an adapter by the echo rule above), which cannot take False. Frames another socket on the host sends (kernel BCM periodics, a second bus) reach a SocketCAN codec by local loopback either way.

Decoder semantics — divergences from cantools

zelos-can decodes per the DBC spec. For well-formed inputs we match cantools exactly.

ARXML

zelos-can cantools
Event name <id>_<frame short-name>; PDUs the frame carries nest under it by name message named by the frame short-name
Unit NoUnit "" None
LINEAR with no or an empty coefficient signal stays raw, load warning file rejected or identity
Container PDU one event per contained PDU, <id>_<frame>/<contained PDU>; unknown header id skipped, counted a list of contained messages
Multiplexed PDU static part under the frame, dynamic part under <frame>/<dynamic PDU> one flat message
Secured PDU authentic PDU decoded under the frame, secoc column unverified freshness and authenticator as signals
E2E e2e_status column (profiles 1, 2, 4, 5, 6, 7, 11, 22) not checked on decode
1C / SM base types two's complement, load warning two's complement

AUTOSAR PDUs

One bus-agnostic pipeline (zelos_network_model::pdu, shared with zelos-packet); a plain signal PDU at byte 0 takes the DBC fast path.

What Behavior
Naming <id>_<frame>; contained, dynamic and (several per N-PDU) CanTp SDUs nest as <id>_<frame>/<PDU>
Container short (24 + 8 bit) and long (32 + 32 bit) headers, System header byte order; id 0 ends the walk; unknown id: warned once, pdu_unknown_contained
Multiplexed static part plus the selected dynamic part; unknown selector: pdu_unknown_selector
Secured Secured I-PDU Header, freshness and authenticator cut by the ARXML lengths; secoc = 0 unverified
E2E PRS E2E R22-11; e2e_status: 0 ok, 1 wrong_crc, 2 wrong_sequence, 3 repeated, 5 not_checked; the first frame is ok
CanTp ISO 15765-2 SF/FF/CF, normal, extended and mixed addressing, CAN FD lengths, one open transfer per connection up to 64 KiB; wrong SN drops it; cantp_completed, cantp_dropped

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):

  • cantools raises at decode time (expected float size of 16, 32, or 64 bits).
  • zelos-can preserves is_float so the schema field stays typed as float, but decode_signal returns None per-frame and the decode_errors metric 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):

  • cantools returns Python int (unbounded — sign is implicit).
  • zelos-can promotes 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 saturate f64 → uN casts 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 (a ValueError) with file and line.
  • Skipped, with a reason in warnings: calibration components, pointers, MSB_FIRST_MSW_LAST / MSB_LAST_MSW_FIRST, BIT_OPERATION, instance OVERWRITE, missing conversions, transports other than UDP/TCP/CAN.
  • Structures: INSTANCEs flatten to inst.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=True raises instead. XCPlite files reference an XCP_104.aml that is rarely shipped alongside.

OBD-II (zelos_can.obd)

Passive, database-free: the obd keyword of CanCodec / CanDecoder and converter --obd: "off" (default), "on" (ISO 15765-4 ids no database defines), or "7df:7e8,..." id pairs. A database message on the id wins; an ARXML DcmIPdu SDU always decodes here.

Event (under the bus, id as for DBC messages) Rows
07e8_OBD2 one per $01 / $22 F4xx response: service, one column per PID (EngineRPM [rpm], CoolantTemp [degC], VehicleSpeed, ControlModuleVoltage, MIL, DTCCount, <Monitor>Available / <Monitor>Complete, SupportedPIDs01_20...), sparse
07df_OBD2/Request service, pid, pid_name, did, frame, data: who asked what
07e8_OBD2/FreezeFrame $02 and $19 04 (J1979-2 snapshot records, frame = record number): frame, the PID columns (FreezeDTC)
07e8_OBD2/DTC one per DTC of $03 / $07 / $0A / $19 42 / $19 55 / $19 56: code (P0133, P0133-00), raw, kind (stored / pending / permanent), status + its 8 ISO 14229-1 bits, severity, count (0 = empty list)
07e8_OBD2/VehicleInfo $09 / $22 F8xx: infotype, vin, calid, cvn, ecu_name, supported, protocol (F810: 1 = J1979-2)
07e8_OBD2/TestResults one per $06 test (mid, tid) or $19 06 record 0x92 result (dtc): uasid, value / min / max scaled (unit), *_raw, pass; supported for bitmap MIDs and $19 1A 92
07e8_OBD2/InUse one per in-use ratio: $09 08 / 0B and $22 F808 / F80B by monitor (general_denominator, ignition_cycles), $19 06 record 0x91 by dtc: numerator, denominator, ratio; supported for $19 1A 91
07e8_OBD2/Clear $04 / $14: success (null while pending), nrc
07e8_OBD2/Raw $08, PIDs outside the table, lists that do not fit their count: service, pid, data
07e8_UDS ISO 14229 that is not OBD (OEM DIDs, sessions, security...): sid, parameter, nrc, data

Legacy ($01-$0A) and J1979-2 ($22 F4xx / F501 / F8xx, $19, $14) land in the same tables; a payload outside the public table is a Raw row, never guessed. obd.pids() is the catalogue, obd.decode(sid, data) the decoder.

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

A CanCodec sends as a J1939 node on any bus it owns:

codec.j1939_claim(name=0x1000, address=0x80)       # usable 250 ms later, if uncontested
codec.j1939_address()                              # 0x80, or None while claiming / lost
codec.j1939_send(0xFECA, dm1).wait(timeout=1)      # > 8 bytes to everyone: BAM
codec.j1939_send(0xEF00, data, destination=0x21).wait(timeout=5)  # to one node: RTS/CTS
codec.j1939_send(0xFECA, dm1, fd=True).wait(timeout=1)  # J1939-22, on a CanCodec(fd=True)

A bus config (zelos_can.codec.CanCodec, on every interface) names the node it acts as in j1939_node, claimed when the bus starts and again on every reopen. Without one, nothing is claimed until j1939_claim, and J1939 sends refuse.

Node identity (j1939_node)
"Tester" a DBC node (BU_) of the bus's databases, read from the Vector J1939 node attributes as CANoe, MATLAB VNT and NI-XNET read them: NmStationAddress (the default 254 is no address: refused), NmJ1939AAC, NmJ1939IndustryGroup, NmJ1939System, NmJ1939SystemInstance, NmJ1939Function, NmJ1939FunctionInstance, NmJ1939ECUInstance, NmJ1939ManufacturerCode, NmJ1939IdentityNumber; an unset one takes the BA_DEF_DEF_ default, else 0
{"name": "0x...", "address": 128} explicit NAME (int or hex) and address 0..253, as Linux j1939acd and python-can-j1939 take them
Errors (at init) unknown node (lists the DBC's nodes), no NmStationAddress, a node without any of the attributes, a field over its bit width, NAME 0
NAME helpers j1939.name_from_fields(identity_number=, manufacturer_code=, ecu_instance=, function_instance=, function=, vehicle_system=, vehicle_system_instance=, industry_group=, arbitrary_address_capable=) and j1939.name_fields(name), J1939-81 layout
Node behavior
Address claim (J1939-81) Address Claimed at priority 6; usable after 250 ms; defends against a higher NAME; a lower NAME wins: Cannot Claim and silence, or, with NAME bit 63, the next free address in 128..247
Request for Address Claimed answered (to everyone or to us) with our claim; once lost, Cannot Claim 0..153 ms later
Send up to 8 bytes one frame; up to 1785 by BAM (packets bam_gap 50..200 ms apart) or RTS/CTS (T3 1250 ms, hold T4 1050 ms, up to 2 retransmit requests), at priority 7, padded 0xFF; one RTS/CTS per destination, one BAM at a time
End of a send J1939Transfer.wait() returns on the last frame sent (BAM) or EOMA (RTS/CTS); raises on abort, timeout (abort reason 3) or address loss; j1939_tx_completed, j1939_tx_aborted, j1939_claim_lost in Metrics
J1939-22 send (fd=True) CAN FD frames with BRS; up to 60 bytes one Multi-PG (one C-PG, TOS 2, no assurance data) at priority; up to 16777215 by FD.TP BAM (segments bam_gap apart, then EOMS) or RTS/CTS (EOMS after the last segment, EOMA within T5 3 s), at priority 7, last segment padded 0xFF; up to 4 BAMs and 8 RTS/CTS per destination at once; address claim stays classic
RTS to us received once claimed (TP or FD.TP): CTS windows of the sender's size (at most 255), never a hold; a missing packet asked for again from the first gap, up to 2 times, then abort reason 5; T2 1250 ms after a CTS, T1 750 ms between packets and before the EOMS, else abort reason 3; then the EOMA, and the PG decodes as any other. Abort reason 1 for another PG on a busy sender (classic) or session (FD), keeping the first (the same PG restarts it), or past 8 FD sessions per sender / 32 transfers; 5 for a malformed RTS; 2 past 1 MiB (FD). An FD transfer decodes only with recv_own_messages (it completes on our EOMA). j1939_rx_completed, j1939_rx_aborted in Metrics

CANopen (zelos_can.canopen)

Decoding is the canopen / canopen_nodes keywords of CanCodec / CanDecoder, on every interface and lane (native socketcan, ExternalBus, offline convert): "auto" (default) is on with at least one node, "on" decodes the protocol for every node-id, "off". The module holds the EDS/DCF reader and the ids and NMT frame a master sends. 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
canopen.cob_id(canopen.TPDO1, 0x20)                      # 0x1A0
canopen.describe_abort(0x06020000)  # "object does not exist in the object dictionary"
Decoder behavior
Nodes {node_id, name, file}; node_id (1..127), else a DCF's [DeviceComissioning] NodeID, with neither a ValueError; name defaults to node{id:02x}; file (EDS/DCF, read non-strict) adds PDOs, typed SDO and learning, an unreadable one is a ValueError
Claimed 11-bit classic frames, never DBC-decoded or unknown: NMT 0x000, SYNC, TIME, EMCY, SDO, heartbeat / guarding 0x700+id and LSS 0x7E4/0x7E5 for every node-id by the predefined set; a configured node's own COB-IDs from its file (every 0x1200 SDO channel, EMCY, SYNC, TIME) and its PDOs
DBC overlap a DBC message wins on an unconfigured node's COB-ID and on SYNC / TIME (warned); on a configured node's SDO, EMCY, heartbeat or PDO COB-ID it is a construction error. A node without a file decodes by the predefined set; its PDOs fall through to the DBC
Unknown nodes protocol frames from an unconfigured node-id count canopen_unknown_node, once per node
canopen_nodes() state is the last NMT state's label (boot_up, stopped, operational, pre_operational), unknown(0xNN) for a byte CiA 301 does not define, None before any

Tables are named {cob:04x} ({cob:08x} for a 29-bit PDO), then the node's name, e.g. 0720_pdu_Heartbeat, 05a0_pdu_SDO, 01a0_pdu_TPDO1.

Table Columns
{cob}_{node}_TPDO{n} / _RPDO{n} one per mapped object
{cob}_{node}_SDO one row per transfer, below
{cob}_{node}_EMCY code, class (by the code's high byte), text (CiA 301 generic codes; empty for profile or vendor ones), error_register, reg_* bits, manufacturer (hex)
{cob}_{node}_Heartbeat state (the byte, labeled by its value table), toggle (guarding), period_ms, late
0000_NMT command, node_id
0080_SYNC / 0100_TIME counter / timestamp_ns, days, ms
07e4_LSS / 07e5_LSS cs, data (hex)
PDOs (nodes with a file)
Mapping from the file (ParameterValue, else DefaultValue), then learned; dummy entries are padding; MPDO is not decoded (warned)
Shared COB-ID one node's TPDO and another's RPDO decodes by the TPDO (warned); a lone RPDO under the consumer's name
Columns the mapped object's name, <Parent>_<Sub> for a RECORD or ARRAY sub, runs of characters other than letters, digits and _ as _ (Read input 8-bit / Input 1 to 8 -> Read_input_8_bit_Input_1_to_8); two entries of one PDO with one name are a construction error; no units
Types BOOLEAN and UNSIGNED8..64 unsigned, INTEGER8..64 signed (sign-extended at the mapped width), each in the smallest integer column holding the mapped bits; REAL32/64 float32/64; VISIBLE_STRING / UNICODE_STRING text up to the first NUL; OCTET_STRING hex. An entry the dictionary lacks is skipped (warned); a PDO with nothing decodable is not claimed
Frames shorter than the mapping: no row (canopen_pdo_short); longer: the excess ignored; a remote request: claimed, no row
Learning (configured nodes)
PDOs a download the server confirms (an aborted one changes nothing) to 0x1400-0x1BFF, in CiA 301 order: COB-ID bit 31 set (invalid, no longer claimed), mapping sub 0 = 0 (disabled), entries, sub 0 = n (enabled), bit 31 cleared (valid, maybe on a new COB-ID)
Remap a layout that differs from one whose table was registered (its first row, or any time with emit_schemas_on_init) gets a new table {cob}_{node}_TPDO{n}_{k}, k = 2, 3, ..., since a table's columns never change (warned once, canopen_pdo_remapped); a layout seen before returns to its table; before registration a remap replaces the table
Other objects 0x1017 sets the producer heartbeat time; 0x1005, 0x1012, 0x1014 move SYNC, TIME, EMCY
Concise DCF a download to 0x1F22 is not parsed (canopen_pdo_unlearned, warned)
Reset NMT reset node / reset communication to the node (or all, node 0) and its boot-up heartbeat (state 0) return what learning changed to power-on values: the file's, or after a confirmed "save" (0x65766173) to 0x1010 sub 1 or 2, the values at that save. PDOs rebind as above. Warned, canopen_pdo_reverted
Heartbeat
late more than 2x the producer time (0x1017 from the file, else learned) after the previous heartbeat; null without a producer time, for a guarding reply, or for a node's first frame
heartbeat_lost in canopen_nodes() once the bus clock is more than 2x the producer time past the last heartbeat (canopen_heartbeat_lost counts transitions); the next heartbeat clears it. No rows are made up
Clock offline (CanDecoder) each frame's time, so a lost node shows at the next frame of any kind; a live CanCodec also checks every half of the shortest producer time (at least 50 ms) on the latest frame's time plus the host time since, so a silent bus shows it too

SDO rows: index, subindex, name, direction (upload|download), kind (expedited|segmented|block, null until the side that picks it spoke), size, data (hex, first 64 bytes), truncated, value, text, abort_code, abort_text, duration_ms, resync.

SDO rows
name the object's column name (as for PDOs) when the node has a file
value a completed transfer typed by the node's dictionary, as a float (signed and REAL correct); null without a type, for strings, and for anything not completed. data is the authority
text VISIBLE_STRING / UNICODE_STRING as text up to the first NUL (data keeps every byte), OCTET_STRING and DOMAIN as hex, numbers as their exact decimal; empty without a type, null when not completed
Closed by the server's last answer; an abort from either side (the client's own timeout shows as abort 0x05040000); a new initiate on the channel (abort_text="superseded", resync, canopen_sdo_resync). No decoder timeout
Stream end CanDecoder.flush() emits a transfer still open with abort_text="incomplete"; a live CanCodec stopping does not
Time when the transfer closed; it started duration_ms earlier
Aborted a download keeps the client's bytes in size / data; value and text stay null (the write did not happen). An abort with no transfer open keeps its index and subindex, direction and kind null
Orphans a frame with no transfer to belong to (a late answer, a capture joined mid-transfer) counts canopen_sdo_resync

A live CanCodec (native socketcan, VirtualBus, ExternalBus: every interface of zelos_can.codec) is also a client:

codec.sdo_read(0x20, 0x1018, 1)            # (node_id, index, subindex=0, timeout=1.0)
codec.sdo_write(0x20, 0x6200, 1, b"\x01")  # (node_id, index, subindex, data, timeout=1.0)
codec.nmt("start", 0x20)  # start | stop | pre_operational | reset_node | reset_communication; node 0: all
Client
Result {value, text, data, size, abort_code, abort_text}; value / text typed as for SDO rows (None / "" without a file, both None when aborted); data the bytes read or written, empty when aborted; a server abort is returned, never raised
Transfers expedited and segmented, both directions, downloads indicate the size; no block transfer; on the node's first SDO channel from its file (0x1200), else 0x600/0x580 + id
Blocking the caller blocks with the GIL released; the transfer runs in the codec's loop, answers matched as frames decode
ValueError node_id outside 1..127, index outside 0..0xFFFF, subindex outside 0..255, empty data, timeout not in (0, 60] s
TimeoutError no answer within timeout (per answer), after sending abort 0x05040000; or the node's quiet period outlasts timeout (nothing sent)
RuntimeError a transfer to the node in progress (one per node); a protocol violation, after sending its abort (unexpected command specifier 0x05040001, toggle 0x05030000, more bytes than indicated 0x06070012, fewer 0x06070013, over 1 MiB without a size 0x05040005); the client queue full; the send failed; the codec stopped (pending calls fail too)
Quiet period after a timeout the node's next request waits until timeout has passed since the abort (the call blocks); frames on its response COB-ID meanwhile never reach a transfer (canopen_client_stale). A node slower than that can still answer late
Shared channel SDO has no transaction id and CiA 301 gives a server channel one client. Before our initiate is answered, an answer or abort for another object, or the other direction's initiate answer, is ignored (canopen_client_stale). Another master's answer or abort for the same object and direction is indistinguishable and taken as ours: a write can report success that the server then rejects. During our segmented transfer, another client's initiate answer aborts ours (0x05040001), and a server abort ends ours with its code. Interleaved with another master's transfers, ours can lose their SDO row (closed superseded or missing) on every lane; a row not marked superseded is correct
Aborts before our initiate is answered, a server abort that does not echo our index and subindex (CiA 301 requires it) is ignored: the call times out
Trace with one client per server channel, each transfer is one SDO row on every lane, decoded from the echo of the client's frames on the lane's clock (VirtualBus and the default ExternalBus() stamp it on decode, so a peer's answer sent with an explicit timestamp= can precede it). With recv_own_messages=False the client's frames are not traced and no SDO row is written; the transfer itself is unaffected. canopen_client_sdo counts transfers answered to the end, canopen_client_timeout timeouts

Metrics: the canopen_* counters above, plus canopen_frames, canopen_sdo_completed, canopen_sdo_aborted, canopen_sdo_crc_error, canopen_short_frame, canopen_heartbeat_late.

EDS/DCF reader

Reads a CiA 306 EDS or DCF in Rust into one catalog dict. Anything it cannot carry faithfully is a load error or skipped and named in warnings, never a silently different value.

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: {index, name, object_type, data_type, sub: {subindex: {name, data_type, bits, access, default, value, low, high, pdo_mappable, ...}}}}; data_type the CiA 301 name, None when unknown (warned); default, value (a DCF's ParameterValue, used first by decoders), low, high: int, float or str by type, a signed type reading an unsigned literal as two's complement; CompactSubObj=N expands to subs 0..N
pdos RPDOs then TPDOs: {kind, n, cob_id, extended, transmission_type, event_timer_ms, entries: [{index, sub, bits, name, data_type, dummy}], ...} from 0x1400-0x1BFF (value, else default; an unstated COB-ID is the predefined one, n <= 4); entries LSB first, dummies are padding; invalid (bit 31), disabled and MPDO ones skipped (warned)
comm resolved sdo server channels, emcy, heartbeat_cob_id, heartbeat_ms, sync, time, sync_overflow, identity (0x1018)
node_id the argument, else a DCF's NodeID; it resolves $NODEID and the predefined COB-IDs, without one they stay "$NODEID+0x..." and PDO cob_id is None
Also file_info, device_info, commissioning, dummy_usage, mandatory_objects, optional_objects, manufacturer_objects, comments, warnings (file:line: ..., file order)

Print canopen.load(path) for the full shape.

  • Values: decimal, 0x hex, octal with a leading 0, $NODEID+n or n+$NODEID; whitespace and ; comments (at line start or after whitespace) ignored. Section and key names are case-insensitive.
  • Skipped, with a reason in warnings: unknown data or object types, unparsable values. Duplicate sections merge and duplicate keys take the later value, warned.
  • Errors: canopen.CanopenError (a ValueError) with file and line for an unreadable file, a line that is neither [section] nor key=value, or a node_id outside 1..127; strict=True raises 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)
# pdu_unknown_contained / pdu_unknown_selector / cantp_completed / cantp_dropped (AUTOSAR PDUs)

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). test_headless_lane.py runs on hw for every role python-can opens (not vcan, monitor or zelos-*; dut through python-can's socketcan, an slcan role, ...): J1939 and CANopen through PythonCanTransport, the genset's decode against the native lane on dut, on the board as it runs (never flashed; a node not heard skips).

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.11a13

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for zelos-can 0.0.11a13
File
zelos_can-0.0.11a13-cp310-abi3-win_amd64.whl CPython 3.10 abi3 Windows x86-64 Details
zelos_can-0.0.11a13-cp310-abi3-manylinux_2_28_x86_64.whl CPython 3.10 abi3 Linux glibc 2.28+ x86-64 Details
zelos_can-0.0.11a13-cp310-abi3-manylinux_2_28_aarch64.whl CPython 3.10 abi3 Linux glibc 2.28+ ARM64 Details
zelos_can-0.0.11a13-cp310-abi3-macosx_11_0_arm64.whl CPython 3.10 abi3 macOS 11.0+ ARM64 Details

Total release size: 17.2 MB

Release files / zelos_can-0.0.11a13-cp310-abi3-win_amd64.whl

Download URL zelos_can-0.0.11a13-cp310-abi3-win_amd64.whl
Size 3.9 MB
Tags CPython 3.10 Windows x86-64 abi3
SHA-256 checksum
How to use checksums
e99e8185e9a9ec602f73a34c7d3e6bce43a004d9b10305a0867bfa4a6d12a6cf
BLAKE2b-256 checksum
How to use checksums
164fa3c7917158f7c64e58493db229494b9d4b76a11b46906894b81524cd2ceb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.15

Release files / zelos_can-0.0.11a13-cp310-abi3-manylinux_2_28_x86_64.whl

Download URL zelos_can-0.0.11a13-cp310-abi3-manylinux_2_28_x86_64.whl
Size 5.0 MB
Tags CPython 3.10 Linux glibc 2.28+ x86-64 abi3
SHA-256 checksum
How to use checksums
41de09baf8f82e9d71a51ba39bb1e0d7117863db7dc1e65879b48579ebf1f546
BLAKE2b-256 checksum
How to use checksums
b338b12a072925cd5933458557d25c4df7e006970e4b1f55b0315b0ff4c9d48b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.15

Release files / zelos_can-0.0.11a13-cp310-abi3-manylinux_2_28_aarch64.whl

Download URL zelos_can-0.0.11a13-cp310-abi3-manylinux_2_28_aarch64.whl
Size 4.7 MB
Tags CPython 3.10 Linux glibc 2.28+ ARM64 abi3
SHA-256 checksum
How to use checksums
b0a2e7d5bab79c85497a5c34c94fbd010bf0fcb96f30e15ee6b3fa67a12373a4
BLAKE2b-256 checksum
How to use checksums
0cf5312ff478e0eb2488ec4576a78fc59dc180e37391de850db99ff4c2bfe35c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.15

Release files / zelos_can-0.0.11a13-cp310-abi3-macosx_11_0_arm64.whl

Download URL zelos_can-0.0.11a13-cp310-abi3-macosx_11_0_arm64.whl
Size 3.6 MB
Tags CPython 3.10 abi3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
c6416bf447300460f114d65888f8c52290bc830e238d7559ac405259cbe27cec
BLAKE2b-256 checksum
How to use checksums
f1edc441d4abdf34334116d88b1244404f0fb060ccfc6c4091857fed78720f6e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.15

Security for zelos-can 0.0.11a13

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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page