L2 · Infrastructure Multi-protocol demultiplexing and TX confirmation over one ICanBus
CanKit.Pro.RawCan¶
Raw-CAN service layer for CanKit: multi-protocol demultiplexing / subscriptions (arc42 §5.3, ADR-5; SRS FR-RAW-010..015) and a TX-confirm abstraction (arc42 §6.3, ADR-7; SRS FR-RAW-030..034).
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.
What is validated, and what is not¶
Validated: Demultiplexing and subscriptions, filter overlap, TX confirmation and concurrency, by the test suite in tests/CanKit.Pro.Tests, over CanKit.Adapter.Virtual and in-repository bus doubles. SendConfirmedAsync has also been run on two hardware adapters, PCAN and Kvaser, in Normal and in Echo mode, with a second adapter as peer; what each returned is recorded in the echo measurements, and TX-Confirm below says what follows from them.
Not validated: SocketCAN and Vector: what they do with an echo is read from the adapter source, not measured. The echo-matching path is tested on a double (ControllableBus) that hands every sent frame back flagged as an echo, which neither measured adapter does in Echo mode. Nothing in this package has run against a conformance tester or a third-party implementation, and the test project references CanKit.Adapter.Virtual and no hardware adapter: the hardware runs are a dated record made with tools/CanKit.Pro.EchoProbe, not part of the suite.
One ICanBusService wraps one ICanBus and turns its single FrameObserved RX stream into
N independent, filtered, read-only ISubscriptions — so several protocol instances (ISO-TP,
J1939, CANopen, …) can each see their own view of the same bus without competing over
ReceiveAsync and without one slow consumer blocking the others.
using CanKit.Core;
using CanKit.Pro.RawCan;
using var bus = CanBus.Open("virtual://demo/0", cfg => cfg.SetProtocolMode(CanProtocolMode.Can20).Baud(500_000));
using var service = new CanBusService(bus);
// Fast path: one 11-bit ID range per protocol instance (no per-frame delegate).
using var isoTp = service.Subscribe(CanIdFilter.Range(0x700, 0x7FF));
// Generic predicate when a range/mask is not enough.
using var custom = service.Subscribe(e => e.Frame.IsExtendedFrame && e.Frame.Len == 8);
await foreach (var e in isoTp.Frames.WithCancellation(token))
{
// e.Frame read-only CanFrameView, no ownership/disposal concerns, and it owns
// its payload -- valid after the adapter has released the RX lease
// e.IsEcho the bus's own echo flag (see below)
// e.ReceiveTimestamp what the adapter recorded; zero on adapters that do not timestamp
}
Each subscription owns its own bounded, drop-oldest buffer (FR-RAW-011). Disposing a
subscription deterministically deregisters it and completes its Frames stream; disposing the
service unwinds all subscriptions and detaches from the bus (FR-RAW-012). Call
subscription.Reconfigure(CanIdFilter) or Reconfigure(predicate) to change filter criteria at
runtime without recreating the subscription (FR-RAW-014); only frames observed after the call follow
the new criterion. This layer is built purely on the public ICanBus.FrameObserved surface, so it
works identically for every adapter.
Known capture gaps¶
Built-in subscriptions also implement the optional ILossAwareSubscription (FR-RAW-016).
DroppedFrameCount counts only frames evicted by that subscription's bounded buffer. A slow
subscriber cannot increase another subscriber's count; filters, echo exclusion, reads,
cancellation and disposal do not increase it. Zero local drops say nothing about losses in an
adapter, driver or upstream pipe. Other ISubscription implementations remain compatible and
may omit this interface: missing diagnostics mean unavailable information, not zero losses.
SubscriptionFrame contains the original CanFrameEvent and a subscription-local
SequenceNumber. The first accepted enqueue is 1; numbers continue across reconfiguration and
wrap modulo 2^64. For one exclusive reader, a number other than the expected successor marks
missing input before that frame, including loss before the first read. Numbers describe enqueue
order under concurrent dispatch, not hardware timestamp order, and cannot be compared across
subscriptions. Both projections consume the same buffer; competing readers can create sequence
gaps without incrementing the overflow counter.
using var capture = service.Subscribe(bufferCapacity: 64);
if (capture is ILossAwareSubscription loss)
{
ulong expected = 1;
await foreach (var item in loss.FramesWithSequence.WithCancellation(token))
{
if (item.SequenceNumber != expected)
{
// Invalidate pending pair/timing evidence here; qualify capture separately.
Console.WriteLine($"Capture gap before #{item.SequenceNumber}; known local drops: {loss.DroppedFrameCount}");
}
expected = unchecked(item.SequenceNumber + 1);
Console.WriteLine($"#{item.SequenceNumber}: CAN-ID {item.FrameEvent.Frame.ID:X}");
}
}
TryReadWithSequence and the inherited WaitToReadAsync provide the same metadata to a
pull-style consumer. The lifetime count is a separate instantaneous sample: an enqueue can
finish updating it after a concurrent reader has obtained a retained frame. Use sequence
numbers to order a gap. The count saturates at long.MaxValue, after which it is a lower bound;
start a new subscription when exact counting is required.
Across a known gap, discard any pending pair and timing evidence. A fresh contiguous run can start a new observation period under the consuming protocol's recovery rules; the lifetime counter does not reset. A locally observed timeout is not proof of remote failure when capture is incomplete. Continuous SRDO supervision and its recovery policy are separate from this RawCan capture contract (#401).
Echoes¶
A bus opened with WorkMode == ChannelWorkMode.Echo reports the host's own transmissions back
through the same RX stream, flagged as echoes. Subscriptions do not deliver them unless asked:
using var quiet = service.Subscribe(); // peer traffic only (the default)
using var trace = service.Subscribe(includeEcho: true); // everything, e.Frame + e.IsEcho
Off by default because frames you sent are not frames you received: a J1939 node that treats its own Address Claim as a competitor's, or a CANopen node that acts on its own PDO, is broken only on the hardware that happens to echo. Where a subscription did not opt in, an echo is dropped before the filter runs, so it never reaches a caller-supplied predicate either.
Two limits make this a convenience rather than a guarantee, and both matter:
The gate only drops what the adapter flags. An adapter that echoes without setting IsEcho
delivers its echo to every subscription no matter what includeEcho says — CanKit.Adapter.Virtual
in ChannelWorkMode.Echo is such an adapter today.
IsEcho is host-scoped, not instance-scoped. It means something on this host sent this, not
I sent this. Several protocol instances may share one ICanBusService (every factory here
documents that), and a sibling's transmission carries the same flag as your own. So a protocol
layer that shares a service asks for echoes instead, and tells its own traffic apart by something
it owns. includeEcho: false is for a single consumer that owns its bus, such as a monitor or a
one-node application.
How far each layer takes that differs, and this package does not promise it uniformly: J1939-TP rejects its own source address, the J1939 node rejects its own NAME on an Address Claim and its own source address on an application PGN, and CANopen rejects its own node id on an EMCY or a heartbeat.
None of them is a blanket self-filter, and the exceptions are the interesting part: a J1939 frame addressed to the node itself is delivered, so a request against your own address is answered; CANopen delivers NMT, SYNC, both SDO directions and RPDOs from its own producer, and still feeds a heartbeat or node-guarding consumer explicitly registered for the local node id. The rule each layer follows is that an explicitly configured or explicitly addressed frame outranks a guess about who sent it.
SendConfirmedAsync is independent of this: it matches echoes on the bus event itself, so withholding
them from subscribers does not affect TX confirmation.
TX-Confirm¶
SendConfirmedAsync gives a uniform "was this frame actually sent" answer regardless of whether the
bus has hardware TX echo enabled:
// Bus opened with CanFeature.Echo + WorkMode = ChannelWorkMode.Echo -> real echo matching.
// Otherwise -> confirmed as soon as the driver accepts the frame (TxConfirmation.IsApproximated).
var result = await service.SendConfirmedAsync(CanFrame.Classic(0x123, new byte[] { 1, 2, 3 }));
if (result.Confirmed)
{
// result.IsApproximated tells you whether this was a real echo or driver-acceptance only.
}
else
{
// result.FailureReason: Timeout, BusOff, or Rejected -- never an indefinite hang.
}
Which adapters can take the echo path. It needs an adapter that hands each sent frame back
flagged as an echo (IsEcho). Measured against CanKit 0.5.6, PCAN in Echo mode hands its own
frames back unflagged and Kvaser hands back none on a quiet bus, so with either of them every
confirmation ends as Timeout although the frame was on the wire; Vector is not measured and is
read from its source as delivering none in Echo mode either. Use ChannelWorkMode.Normal with
those adapters: the approximated confirmation is what they support. The measurements are in
docs/reviews/2026-10-06-echo-hardware-measurements.md.
BusOff is produced when the adapter raises ICanBus.FaultOccurred while ICanBus.BusState reads
BusOff. No CanKit 0.5.6 adapter does that — they surface a bus-off through BusState and error
frames, and under the default CanExceptionPolicy raise a fault only when their receive loop dies —
so on those adapters a send outstanding during a bus-off ends as Timeout (#348). A bus fault is
relayed through BackgroundExceptionOccurred either way, so a consumer that only reads
subscriptions learns of it there — on the service: a subscription carries no fault signal of its
own, and its Frames stream stays open.
Byte-identical sends pending at the same time are matched to their own confirmation in FIFO
order, never cross-matched (FR-RAW-031). The matching is by content: the echo of a send that has
already timed out or been cancelled cannot be told from the next identical send's and confirms that
one (#349). The per-call timeout is configurable (FR-RAW-034): it must be positive, and one above
int.MaxValue ms (about 24.8 days, the longest delay the runtime's timer measures) is clamped to
that bound, so TimeSpan.MaxValue waits about 24.8 days. Disposing the
service cancels any outstanding SendConfirmedAsync calls rather than leaving them to time out, on
both paths: a call waiting for its echo, and a call whose driver transmit is still pending on a
bus whose TransmitAsync completes asynchronously (#352). Either ends in
OperationCanceledException; a frame already handed to the driver may still go out.
A CAN-FD payload of a length no CAN-FD frame carries (anything but 0–8, 12, 16, 20, 24, 32, 48 or
64 bytes) is sent, not refused. Its DLC rounds up to the next of those lengths, so on a real bus it
goes out padded to that length. The echo path
matches by content, so on an adapter that echoes the padded frame — CanKit 0.5.6's Vector CAN-FD
transceiver builds a received frame's length from its DLC — the echo never matches and the
confirmation ends as Timeout although the frame went out. Send canonical lengths. Refusing the
others is kept for the next major version (#352).
The caller's cancellation token is consulted immediately before the driver call — on the echo
path inside the send lock, so after any wait behind another send's driver call — and a token
cancelled up to that point abandons the send with OperationCanceledException: nothing reaches
the driver (#294). A frame the driver has been handed goes out; the CanKit 0.5.6 adapters ignore
the token inside their transmit, so the driver call is where a cancellation stops being able to
hold a frame back.
A confirmation carries two host-monotonic readings that bracket the driver call:
HostHandoffTimestamp immediately before it and HostTransmitTimestamp immediately after. On the
echo path both are taken inside the service's send lock, and a Timeout or BusOff result
carries them too, since the driver call returned before the echo wait began. The approximated path
has no send lock, and its Timeout means the driver's asynchronous transmit had not completed, so
that result carries neither. A Rejected result carries neither on either path; a missing reading
is zero. A response deadline starts at the second; whether a received frame can be a
response to this transmission at all is decided against the first — the call itself can include
a peer's answer on an in-process bus, or a completion callback on an asynchronous adapter, so a
fast reply can be stamped before the second reading (#146, #147).
Migrating from 1.2.x¶
Subscriptions used to yield a bare CanFrameView. They now yield a CanFrameEvent carrying the
frame plus the two facts the bus already knew and the demux was discarding:
// before
using var sub = service.Subscribe(view => view.ID == 0x123);
await foreach (var frame in sub.Frames) Use(frame.Data);
// after
using var sub = service.Subscribe(e => e.Frame.ID == 0x123);
await foreach (var e in sub.Frames) Use(e.Frame.Data);
TryRead gains an out CanFrameEvent, and predicates and callbacks take CanFrameEvent. Reach
the frame through .Frame.
ISubscription.WaitToReadAsync is the pull-style counterpart to Frames beside TryRead: a
consumer that must also drain the buffer from another thread — a deadline check that cannot wait
for the consumer's own scheduling — waits with it and drains under its own lock with TryRead,
so the two cannot reorder frames the way a caller overtaking an enumerator would. CanKit.Pro.IsoTp
reads its subscription this way.
On a bus not configured for echo, nothing else changes. On an echo bus, a subscription now
withholds host echoes unless it passes includeEcho: true — read the Echoes section above
before choosing, in particular the part about the flag being host-scoped rather than
instance-scoped.
Install¶
dotnet add package CanKit.Pro.RawCan
# plus a CanKit adapter for the hardware you actually talk to, e.g.
dotnet add package CanKit.Adapter.Virtual # loopback, no hardware
# dotnet add package CanKit.Adapter.PCAN # Kvaser, Vector, SocketCAN, ZLG, ... likewise
Dependencies: CanKit.Abstractions.
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.