Metadata-Version: 2.3
Name: zelos-can
Version: 0.0.6
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: POSIX :: Linux
Requires-Dist: zelos-sdk >=0.0.10a5
Requires-Dist: python-can >=4.0 ; 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 (optional — only needed if you want to use
# can.Bus(interface="zelos-socketcan", ...)):
pip install 'zelos-can[python-can]'
```

## 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,
# auto-reconnect on link-down — all handled in Rust.
```

Compatible with `can.Notifier`, `bus.send_periodic`, `bus.state`, and the
`bus.socket` escape hatch. Drop-in for `interface="socketcan"`.

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

```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/.mf4 file via python-can readers:
for msg in can.BLFReader("capture.blf"):
    decoder.decode_message(msg)
```

`decode_frame` and `decode_message` release the GIL for the duration of the
Rust decode + trace emit — the caller thread stays free.

Complete examples in [`examples/`](./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",
)
```

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

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

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

