Metadata-Version: 2.3
Name: zelos-can
Version: 0.0.11a5
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Requires-Dist: zelos-sdk >=0.0.12a3
Requires-Dist: python-can[serial] >=4.0 ; extra == 'python-can'
Requires-Dist: cantools ; extra == 'python-can'
Provides-Extra: python-can
Summary: Rust-first CAN codec for the Zelos ecosystem. SocketCAN tracing with DBC signal decoding.
Author-email: Zelos Cloud <info@zeloscloud.io>
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# 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

```bash
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](#feature-matrix)).

## Three ways to use it

### 1. Drop-in python-can bus (live capture on Linux)

```python
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:

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

```python
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/`](./examples):
- [`file_replay.py`](./examples/file_replay.py) — .asc/.blf/.trc/.mf4/.log/.csv → .trz
- [`python_can_forward.py`](./examples/python_can_forward.py) — any python-can interface → live trace + .trz
- [`udp_source.py`](./examples/udp_source.py) — skeleton for a network-CAN gateway
- [`virtual.py`](./examples/virtual.py) — in-memory VirtualBus + native Rust codec
- [`socketcan.py`](./examples/socketcan.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:

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

```python
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 |

```python
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`):
- `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`.

```python
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:** `INSTANCE`s 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.

## 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`.

```python
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:

```python
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)
```

| 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 | refused, TP.CM (or FD.TP.CM) abort reason 1 |

## CANopen (`zelos_can.canopen`)

Decoding is the `canopen` / `canopen_nodes` keywords of `CanCodec` / `CanDecoder`, on
every 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`.

```python
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) may come from a DCF's `[DeviceComissioning] NodeID`, neither is 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; on a configured node's COB-ID or PDO 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 |
| Warnings | `canopen_warnings()` lists each once: files, construction, learning |

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 (label, or `unknown(0xNN)` for a byte CiA 301 does not define), 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` / ssh) is also a client:

```python
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 (unexpected command specifier: abort 0x05040001, toggle: 0x05030000, upload short of its indicated size: 0x06070013); the codec stopped (pending calls fail too) |
| Quiet period | SDO has no transaction id, so a late answer to a timed-out request looks like the next one's. 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 | an initiate answer or abort for another object, or the other direction's initiate answer, is another client's (or stale): ignored (`canopen_client_stale`). CiA 301 gives a server channel one client; with a second master on it, passive SDO rows may show its transfers superseded (`resync`) on a lane that does not echo TX |
| Trace | each transfer is one SDO row on every lane: decoded from the loopback copy, on the lane's clock, where it echoes TX (`VirtualBus`, `ExternalBus(loopback=True)`, ssh's candump), else when sent (native socketcan, `recv_own_messages=False`); `canopen_client_sdo` counts transfers answered to the end, `canopen_client_timeout` timeouts |

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

```python
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 |
|---|---|
| `file_info` | `{key: value}` from `[FileInfo]`, as written |
| `device_info` | vendor_name, vendor_number, product_name, product_number, revision_number, order_code, baud_rates (kbit/s with `BaudRate_<k>=1`), granularity, nr_of_rxpdo, nr_of_txpdo, lss_supported, dynamic_channels_supported, compact_pdo, simple_boot_up_master, simple_boot_up_slave, group_messaging; None where not stated |
| `dummy_usage` | `[index]` with `DummyXXXX=1` |
| `mandatory_objects`, `optional_objects`, `manufacturer_objects` | `[index]` from those sections |
| `commissioning` | node_id, node_name, baudrate (kbit/s), net_number from a DCF's `[DeviceComissioning]`, or None |
| `comments` | `[Comments]` lines joined by newlines, or None |
| `node_id` | the `node_id` argument, else `commissioning.node_id`, else None |
| `objects` | `{index: object}`, below |
| `comm` | resolved communication objects, below |
| `pdos` | RPDOs then TPDOs, by number, below |
| `warnings` | `file:line: ...` where known, in file order |

| Object | |
|---|---|
| object | index, name, object_type (`VAR` `ARRAY` `RECORD` `DOMAIN` `DEFTYPE` `DEFSTRUCT`), data_type, `sub: {subindex: sub}`; a VAR, DOMAIN or DEFTYPE has sub 0 only, built from its own section |
| sub | name, denotation, data_type, bits, access, default, value, low, high, pdo_mappable, obj_flags |
| `data_type` | CiA 301 name: BOOLEAN, INTEGER8..64 (incl. 24/40/48/56), UNSIGNED8..64, REAL32, REAL64, VISIBLE_STRING, OCTET_STRING, UNICODE_STRING, TIME_OF_DAY, TIME_DIFFERENCE, DOMAIN; None when unknown (warned) |
| `bits` | encoded width; None for strings and DOMAIN |
| `access` | `ro` `wo` `rw` `rwr` `rww` `const` or None |
| `default`, `value`, `low`, `high` | `DefaultValue`, `ParameterValue` (DCF; decoders use it first), limits: int for integer types (a signed type reads an unsigned literal as two's complement: 0xFFFF for INTEGER16 is -1), float for REAL32/64, str as written for strings, OCTET_STRING, DOMAIN and unknown types; None when empty. Without a node id a `$NODEID` value is the str `"$NODEID+0x..."` |
| `CompactSubObj=N` | sub 0 is NrOfObjects (UNSIGNED8, ro, default N); subs 1..N copy the object's keys, named ParameterName + n unless `[XXXXName]` names them; `[XXXXValue]` entries are their value |

| PDO | |
|---|---|
| Keys | kind (`TPDO` or `RPDO`), n, cob_id, extended, rtr_disallowed, transmission_type, inhibit_100us, event_timer_ms, sync_start, `entries: [{index, sub, bits, name, data_type, dummy}]` |
| Source | 0x1400/0x1600 + n - 1 (RPDO), 0x1800/0x1A00 + n - 1 (TPDO), value else default |
| `cob_id` | the 11-bit id, or the 29-bit one with `extended` (bit 29); `rtr_disallowed` is bit 30; unstated: the predefined one (n <= 4); None (warned) when it needs an unknown node id |
| Skipped (warned) | bit 31 (invalid), mapping sub 0 = 0 (disabled), MPDO, a missing mapping object or entry |
| `entries` | mapping order, LSB first; dummy (index 0x0001-0x001F, sub 0) entries are padding; an entry naming an object not in the file keeps its bits, name and data_type None (warned) |

| `comm` | |
|---|---|
| `sdo` | `[{n, request: {cob_id, extended}, response: {cob_id, extended}}]`, server channels 0x1200-0x127F; 0x1200 defaults to 0x600/0x580 + node id; bit 31 set: skipped, warned |
| `emcy` | `{cob_id, extended}` from 0x1014, default 0x80 + node id; None (warned) when invalid or unresolved |
| `heartbeat_cob_id`, `heartbeat_ms` | 0x700 + node id, or None; 0x1017 |
| `sync`, `time` | `{cob_id, extended, producer}` from 0x1005; `{cob_id, extended, producer, consumer}` from 0x1012; or None |
| `sync_overflow` | 0x1019 (0: SYNC carries no counter) |
| `identity` | `{vendor_id, product_code, revision, serial}` from 0x1018, or None |

- **Node id:** the `node_id` argument, else the DCF's `NodeID`. Resolves `$NODEID` and
  the predefined COB-IDs; without one they stay `"$NODEID+0x..."` and PDO `cob_id` is None.
- **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

```python
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.

