Skip to content

L3 · Transport ISO 15765-2 (ISO-TP) codec and channel over CAN and CAN FD

CanKit.Pro.IsoTp

ISO 15765-2 (ISO-TP) implementation for CanKit (CanKit.Pro). The package now ships two halves:

  1. Codec — deterministic, side-effect-free builders and parsers for the four ISO-TP PCI frame types (Single Frame, First Frame, Consecutive Frame, Flow Control) on classic CAN and CAN-FD.
  2. Runtime (IIsoTpChannel) — an actor-driven channel that composes on top of the CanKit.Pro L2 services (CanKit.Pro.RawCan demux + SendConfirmedAsync, CanKit.Pro.Actor, CanKit.Pro.Reliability deadlines). Segments outbound PDUs into SF/FF/CFs, honors peer Flow Control (BS/STmin/Wait/ Overflow) and enforces N_As/N_Bs/N_Cr timers, reassembles inbound PDUs (SN-checked), and delivers them via ReceiveAsync / ReceiveAllAsync / DatagramReceived.

Status: 1.3.0 is the first stable release: from 1.3.0 on the public API follows SemVer, so a breaking change costs a major version. 1.0.0 – 1.2.3 were published as stable before the API had been reviewed against the specifications; they are unlisted and deprecated on nuget.org and should not be used. See Versioning. CAN-FD long-payload cases still get the least coverage of the two halves.

What is validated, and what is not

Validated: The frame codec (unit and property tests), and the channel over CanKit.Adapter.Virtual, directly or through a controllable bus double: segmentation, Flow Control (BS, STmin, Wait, Overflow), the N_As/N_Bs/N_Cr timers, reassembly, functional addressing and CAN-FD, by the test suite in tests/CanKit.Pro.Tests. STmin pacing is tested on a clock the test drives, so the interval is checked exactly and no real elapsed time is measured.

Not validated: Conformance to ISO 15765-2 as a tester or a foreign ISO-TP stack would judge it, and STmin spacing in real time, on any host or adapter, where it carries scheduling and adapter latency. Nothing in this package has run against real CAN hardware, a conformance tester or a third-party implementation: the test project references CanKit.Adapter.Virtual and no hardware adapter.

Scope

  • IsoTpFrameCodec — bounds-safe PCI parser, BuildSingleFrame / BuildFirstFrame / BuildConsecutiveFrame / BuildFlowControl, correct classic-CAN vs CAN-FD DLC/capacity, correct First-Frame length encoding including the 32-bit CAN-FD escape form (lengths > 4095), and bounds-checked PCI parsing that never throws IndexOutOfRangeException on short frames.
  • IsoTpFrameCodec.EncodeStMin / DecodeStMin — full ISO 15765-2 STmin range including the commonly-used 0 ms and 1 ms values (Encode) and the reserved bands 0x80..0xF0 and 0xFA..0xFF which decode to 127 ms (0x7F) instead of throwing.
  • IsoTpFrameCodec.NextConsecutiveSequenceNumber — Consecutive-Frame sequence numbering that starts at 1 after the First Frame and wraps 0..15.
  • Pci / PciType / FlowStatus — parsed Protocol-Control-Information view.
  • IsoTpEndpoint / IsoTpAddressingMode — minimal addressing value type covering Normal, NormalFixed, Extended and Mixed addressing for codec purposes only.

Runtime — IIsoTpChannel

  • IsoTp.Open(ICanBus, IsoTpEndpoint, IsoTpChannelOptions?) — opens a channel that owns a private CanBusService around the supplied bus.
  • IsoTp.Open(ICanBusService, IsoTpEndpoint, IsoTpChannelOptions?, leaveOpen) — opens a channel on an existing service (allows multiple ISO-TP endpoints to multiplex over the same physical bus, SRS FR-TP-018).
  • SendAsync(ReadOnlyMemory<byte>, CancellationToken) — sends one PDU; task completes on TX-confirm of the last frame. SendWithTransmitStampAsync returns IsoTpTransmitStamps, bracketing the driver call for the last frame as the bus service reports it from inside its send lock: just before the handoff (a caller's cutoff for what can still be a response to this PDU — a peer answers only a complete request) and no later than the driver accepted it (where a response deadline starts); the two are not interchangeable (#146). Faults with IsoTpTimeoutException, IsoTpOverflowException, IsoTpWaitFrameLimitExceededException, or IsoTpSendRejectedException on the corresponding ISO 15765-2 error cases.
  • ReceiveAsync / ReceiveAllAsync — two surfaces onto the same bounded, drop-oldest PDU inbox (bounded to IsoTpChannelOptions.ReceiveBufferCapacity, default 64), in arrival order. The drop is silent — no BackgroundExceptionOccurred, no counter — and takes a queued reassembly-abort fault as readily as a PDU, so a channel nobody reads from loses PDUs without a trace once the inbox is full. What is queued before the channel is disposed, or before its bus service is lost, is still delivered; an empty inbox then ends a receive with InvalidOperationException (disposed) or with ObjectDisposedException / the subscription's own failure (service lost), the latter also raised once through BackgroundExceptionOccurred. DatagramReceived is not one of them: each completed PDU is raised on its own thread-pool work item, so handlers may run concurrently and out of order; nothing is raised for a frame handled after Dispose has begun, but a handler already queued may still run. ReceiveWithArrivalAsync returns an IsoTpReceivedPdu stamped with the arrival of its last frame and of its first (FirstFrameArrivalTimestamp), because an application deadline such as UDS P2 ends with the first frame; GetReceptionsInProgress reports the multi-frame receptions that have begun but not completed, oldest first, each as an IsoTpReceptionInProgress — that stamp, the announced length and the First Frame's data bytes, so the application layer can tell whose response it is. A record is published when the First Frame is taken from the subscription, before the channel's actor has processed it (several can be pending while the actor is behind), and withdrawn on its outcome or if the actor refuses the frame; the call itself drains what the demux has buffered first, so the answer does not wait for the reader task's scheduling either. A caller waiting on one re-checks rather than waiting unboundedly. DiscardPendingPdus drains the demux buffer the same way before clearing, so a frame buffered at discard time is part of what it drops. SettleAsync drains it the same way and completes once the actor has taken everything queued so far, without dropping anything: for a decision taken at a deadline, what the inbox does not hold after it did not arrive before the call — a Single Frame stamped in time can otherwise still be on its way when the deadline fires. A CancellationToken ends the wait, not the settling: what the demux buffered has been handed to the actor by then. DiscardPendingPdus(long) drops what arrived before the caller's own stamp rather than before now, so a caller that reads the inbox after taking the stamp and discards after reading has seen everything it drops, and a frame from between the read and the discard is kept.
  • Timings: IsoTpChannelOptions.NAs (TX-confirm), NBs (peer-FC wait), NCr (next CF wait) and WftMax (max consecutive Wait FCs) are configurable; defaults are conservative 1 s / 10.
  • Reception limits: a First Frame announcing more than MaxReceivePduLength (default 65 535 bytes; classic CAN is bounded at 4095 by the codec anyway) is answered with FC(OVFLW) and nothing is allocated for it, so a CAN-FD escape First Frame cannot make the process reserve the ~2 GB it may announce. A Consecutive Frame whose CAN_DL is not the First Frame's, unless it is the last one, is ignored as ISO 15765-2 §9.8 requires — it is not copied short. A First Frame announcing no more than a Single Frame of the same CAN_DL could carry is ignored too (§9.6.3.1): no Flow Control, no reassembly (#56).
  • Flow parameters: the sender takes BS and STmin from the first FC.CTS of a transfer and ignores the values a later FC.CTS carries, as ISO 15765-2 requires (#56).
  • Echoes: a channel receives on RxCanId and transmits on TxCanId, so on an echo-capable bus its own frames never match its filter and host echoes are kept — two reciprocal channels in one process are peers. The one exception is an endpoint with TxCanId == RxCanId and no address-extension byte telling the directions apart (Normal addressing, or Mixed with its one shared byte): there the host echo flag is the only thing telling the channel's frame from the peer's, and host echoes are withheld (#56). Extended addressing on one identifier keeps them, the source and target bytes doing the telling.
  • BackgroundExceptionOccurred is raised on the thread pool, never on the channel's actor, one report at a time in the order they were made, and each handler is called on its own (#356, #381). A reassembly abort has faulted the inbox before its report goes out, so a handler may call ReceiveAsync, DiscardPendingPdus or SendAsync, or dispose the channel, without deadlocking the actor. Called on the actor itself -- other work on an injected actor -- DiscardPendingPdus runs its clear inline instead of waiting on the loop it is on (#56).

Timing accuracy — STmin pacing (NFR-003)

The sender paces Consecutive Frames by the peer's advertised STmin using the L2 DeadlineScheduler (actor-driven, event-based — no busy wait). On general-purpose operating systems the effective CF spacing is STmin + OS scheduling latency, with no real-time guarantee, and nothing in this repository measures how large that latency is on any host. Sub-millisecond STmin values (0xF1..0xF9, 100–900 µs) are honored as-is but bottom out at the platform timer resolution.

What the suite does verify is the logic, not the wall clock: tests/CanKit.Pro.Tests/TestCases/IsoTp/IsoTpStminTimingTests.cs advances a clock the test drives and checks that the sender releases exactly one Consecutive Frame per STmin interval. The requirement NFR-003 asks for a documented accuracy (for example ±1 ms); that figure is a target, not a measured result.

Functional (1:N) addressing — IsoTpFunctionalClient (FR-TP-019)

Per ISO 15765-2 §9 / ISO 14229-1 §7.5.4, a tester can broadcast a request to all ECUs on the bus using a shared functional CAN identifier and collect Single-Frame responses from multiple ECUs.

// Open a functional client (owns its own CanBusService).
using var client = IsoTp.OpenFunctional(
    bus,
    functionalTxCanId:          0x7DF,
    responseRxCanIdRangeStart:  0x7E8,
    responseRxCanIdRangeEnd:    0x7EF);

// Broadcast a UDS DiagnosticSessionControl(DefaultSession) request and collect all replies
// received within 25 ms.
IReadOnlyList<IsoTpFunctionalResponse> responses = await client.SendAndCollectAsync(
    pdu: new byte[] { 0x10, 0x01 },
    window: TimeSpan.FromMilliseconds(25));

foreach (var r in responses)
    Console.WriteLine($"ECU 0x{r.SourceCanId:X3}: {BitConverter.ToString(r.Data)}");
  • IsoTp.OpenFunctional(ICanBus, …) — client owns a private CanBusService.
  • IsoTp.OpenFunctional(ICanBusService, …, leaveOpen) — shares an existing service with physical IIsoTpChannel instances (disjoint ID ranges required, FR-TP-018).
  • SF-only send (ISO 15765-2 §9.4 restriction): a PDU that exceeds the Single-Frame capacity for the configured frame kind faults the task with InvalidOperationException.
  • SF-only collection: First-Frame responses (which would require per-ECU physical Flow-Control addresses) are silently dropped; Single-Frame responses from all ECUs within the range are collected in arrival order.
  • IsoTpFunctionalOptions configures IsExtendedCanId, UseCanFd, UsePadding, PaddingByte, and NAs (TX-confirm timeout).
  • When the request went out: SendWithTransmitStampAsync and SendAndCollectWithTransmitStampAsync return IsoTpTransmitStamps — the instant the frame was handed to the driver and the instant it was transmitted — for a caller that keeps a deadline from the transmission. A response that arrived before the handoff answers something else (the subscription is made before the send, and another sender may hold the service's transmit lock in between) and is left out of the collection.
  • Listening across collections: CollectResponsesAsync subscribes per call, so a response that arrives between two calls — or between a SendAsync and the first call — is missed. client.Listen() subscribes once and returns an IsoTpFunctionalListener whose CollectAsync(window) collects from that standing subscription: obtained before the send, it hears the fastest reply, and what arrives between two collections is buffered for the next. Dispose it to end the subscription.

Open ISO 15765-2 questions

These behaviours of the package depend on what ISO 15765-2 says, and its text has not been checked for them — the reviews that raised them cited it from memory (#353). Until it is, each behaves as described here and nothing is changed:

  1. No N_Ar. A Flow Control this channel sends while receiving is sent on its own, with NAs as its confirmation timeout; if it is not confirmed, an IsoTpException is raised through BackgroundExceptionOccurred and the reception goes on, bounded by NCr as any other. Open: whether an unconfirmed Flow Control has to abort the reception (N_Ar).
  2. Where N_Cr starts. After a Flow Control, NCr runs from the moment the channel queues that Flow Control for sending, not from its confirmation, so the peer's time includes the Flow Control's own way to the wire. Open: whether N_Cr starts at the Flow Control's confirmation.
  3. Reserved FlowStatus. A Flow Control with a FlowStatus above 2 does not parse (TryParsePci returns false) and is dropped without notice; the transfer it was meant for keeps waiting and ends with IsoTpTimeoutException when NBs runs out. Open: whether it has to abort the transfer at once (N_INVALID_FS).
  4. Escape First Frame beyond int.MaxValue. A CAN-FD escape First Frame whose FF_DL is above int.MaxValue does not parse and is dropped without a Flow Control, while one above MaxReceivePduLength but within int.MaxValue is answered with FC(OVFLW). Open: whether the larger one has to be answered with FC(OVFLW) too.
  5. STmin rounding. EncodeStMin rounds a value of 1 ms or more to the nearest millisecond (half up, capped at 127 ms) and truncates a smaller one down to its 100 µs step, but not below 100 µs (under 1 µs encodes as 0). So LocalStMin = 190 µs advertises 100 µs, 999 µs advertises 900 µs and 1.4 ms advertises 1 ms: less separation than was asked for. Open: whether a minimum separation has to be rounded up.
  6. No TX_DL, no BRS. With UseCanFd the sender fills frames up to 64 bytes; no smaller TX_DL can be configured. The channel never sets a frame's bit-rate-switch (BRS) flag. Open: whether either has to be configurable.
  7. Section numbers. The ISO 15765-2 clauses cited in this package's XML docs and comments (IsoTpChannelOptions, IsoTpChannel, the codec) have not been reconciled to one edition of the standard or checked against its text; read them as pointers, not as references.
  8. STmin before the first Consecutive Frame. After every FC(CTS) — the first of a transfer and each one after a block — the sender waits STmin before the first Consecutive Frame, not only between Consecutive Frames. That is the conservative reading. Open: whether the first Consecutive Frame after a Flow Control may go out at once.

Non-scope (yet)

  • Multi-frame (FF/CF) response reassembly in functional sessions (requires caller to supply a per-ECU physical TX address for Flow-Control replies).
  • No vendor-SDK references, ever.
  • The legacy CanKit.Transport.IsoTp prototype (and its Abstractions API/Transport surface plus the PCAN native ISO-TP register) has been removed; this package is the sole ISO-TP implementation path.

Fixes over the prototype (see review §1.1)

The codec is the specification-compliant replacement for the removed legacy prototype codec and deliberately avoids the following defects:

  1. Inverted CAN vs CAN-FD frame kind — this codec is agnostic; it returns the frame payload bytes plus the intended CAN kind, callers construct the CAN frame with the correct kind (FR-TP-003).
  2. Flow-Control frames now carry PCI type 0x3 (not the First-Frame nibble) (FR-TP-004).
  3. Padding is applied after the BS/STmin bytes and never overwrites them (FR-TP-004).
  4. First-Frame length high-nibble is composed with correct operator precedence (((data[0] & 0x0F) << 8) | data[1]), so lengths in [256, 4095] round-trip (FR-TP-005).
  5. EncodeStMin accepts the commonly-used 0 ms and 1 ms values (FR-TP-006).
  6. DecodeStMin maps the reserved raw values 0x80..0xF0 and 0xFA..0xFF to 127 ms (0x7F) per ISO 15765-2 instead of throwing (FR-TP-007, FR-RAW-052).
  7. Consecutive-Frame sequence numbers start at 1 and wrap 0..15 (FR-TP-008).
  8. TryParsePci is bounds-safe and never throws IndexOutOfRangeException, even for a 1-byte frame or a truncated Flow-Control frame (FR-TP-007).
  9. Classic-CAN single frames are always ≤ 8 bytes (FR-TP-015).
  10. BuildSingleFrame rejects a zero-length payload at build time: ISO 15765-2 does not define a Single Frame with SF_DL == 0, so producing such a frame would yield bytes no conformant peer could parse (bugbot 3594958440).
  11. TryParsePci requires an isCanFd argument so the Single-Frame escape header (0x00 LEN …) and the First-Frame escape header (0x10 0x00 LEN[4] …) are only accepted on CAN-FD frames; on classic CAN those bit-patterns are invalid and are rejected instead of being mis-parsed as escape headers (bugbot 3594958440 / 3594958445).

Status: codec plus runtime channel, both shipped. See the note at the top of this file on the withdrawn 1.0.0 – 1.2.3 releases.

Install

dotnet add package CanKit.Pro.IsoTp

# plus a CanKit adapter for the hardware you actually talk to, e.g.
dotnet add package CanKit.Adapter.Virtual   # loopback, no hardware

Dependencies: CanKit.Abstractions, CanKit.Pro.Actor, CanKit.Pro.RawCan, CanKit.Pro.Reliability.

Part of CanKit.Pro — higher CAN protocol layers built on top of CanKit, which is consumed as a NuGet package rather than forked.

License

MIT — see LICENSE. CanKit itself is a separate project licensed under Apache-2.0; see THIRD-PARTY-NOTICES.md.