Metadata-Version: 2.3
Name: zelos-can
Version: 0.0.11a13
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: canopen >=2.4.1 ; extra == 'python-can'
Requires-Dist: zelos-can[python-can] ; extra == 'diag'
Requires-Dist: can-isotp >=2.0.0 ; extra == 'diag'
Requires-Dist: doipclient >=1.2.2, <1.3 ; extra == 'diag'
Provides-Extra: python-can
Provides-Extra: diag
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")
```

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

```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 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|off|7df:7e8,...`: passive OBD-II / UDS decode, default `off`) |
| `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 |

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

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

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

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

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

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

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

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

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

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

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

