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.
Validated: The communication profile as this repository reads CiA 301 — SDO server and client, PDO, NMT, heartbeat, node and life guarding, SYNC, EMCY, EDS/DCF loading — by the test suite in tests/CanKit.Pro.Tests, between instances of this implementation and against raw frames the tests send, over CanKit.Adapter.Virtual.
Not validated: CiA 301 conformance as a tester would judge it, and any real CANopen device or third-party master. Device descriptions are tested against files and descriptions written for the tests, not against ones shipped by real devices. 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.
CANopen (CiA 301) node implementation for CanKit.Pro. Provides an in-process
ICanOpenNode whose object dictionary carries the CiA 301 communication profile and drives the
node's behaviour: an SDO server and client, a PDO engine with every transmission type of
Table 72, an NMT slave state machine, heartbeat producer/consumer, node guarding and life
guarding, a SYNC producer/consumer and structured EMCY — all composed on the CanKit.Pro L2
pipeline (ICanBusService / IProtocolActor / DeadlineScheduler), exactly like
CanKit.Pro.IsoTp, CanKit.Pro.J1939Tp and CanKit.Pro.Uds.
One ICanOpenNode serves both roles a CANopen application takes: the device (its own object
dictionary, the PDOs it produces and consumes, configurable by a master over SDO) and the tool
or master (SDO client, NMT master, heartbeat and node-guarding consumers, SYNC producer). Two
or more nodes may share one ICanBusService, so a master and simulated slaves can coexist on a
virtual bus in one process.
This is a subset of CiA 301, not a complete implementation of it. What the subset covers is
every requirement of SRS §4.3.2 — the requirements this repository set itself. FR-CO-013 to
FR-CO-024 are the device-role scope the maintainer decided on 16 September 2026
(docs/reviews/2026-09-15-canopen-scope.md), FR-CO-025 to FR-CO-028 the EDS/DCF path of that
scope (#132); FR-CO-029 to FR-CO-034 are the master and tool role decided for #131
(docs/reviews/2026-09-26-canopen-master-tool-scope.md). The SRS's "Quelle" column says for each
of them whether a standard requires it, the architecture does, or a maintainer decision does.
| SRS id | Feature |
|---|---|
| FR-CO-001 | Local object dictionary with typed read/write, data-type enforcement and per-entry PDO mappability |
| FR-CO-002 | SDO expedited transfer (payloads ≤ 4 bytes) |
| FR-CO-003 | SDO segmented transfer (payloads > 4 bytes, toggle-bit protocol) |
| FR-CO-004 | SDO block transfer (CiA 301 §7.2.4.3.15) — client + server, download + upload, blksize negotiation, optional CRC-16/XMODEM |
| FR-CO-005 | TPDO/RPDO mapping held in the mapping records 1600h–1603h / 1A00h–1A03h: byte-aligned, up to 8 bytes, written through ConfigureTpdo/ConfigureRpdo or over SDO by the CiA 301 §7.5.2.36/§7.5.2.38 re-mapping procedure |
| FR-CO-006 | Event-driven, timer-driven and SYNC-triggered TPDO transmission; change-of-state emission on application OD writes (CanOpenNodeOptions.EnableChangeOfStateTpdo, default on; bus-originated writes never re-trigger, so no echo loops) |
| FR-CO-007 | NMT master + slave state machine (Start / Stop / Pre-Op / Reset Node / Reset Communication) |
| FR-CO-008 | Heartbeat producer + consumer timeout event |
| FR-CO-009 | Node guarding (CiA 301 §7.2.8.3.2.1) — RTR-based consumer + producer, life-time timeout event |
| FR-CO-010 | SYNC producer and consumer |
| FR-CO-011 | EMCY encode/decode + structured receive event |
| FR-CO-012 | Uses the L2 ICanBusService demux (subscription with COB-ID filter) |
| FR-CO-013 | Communication-profile objects created at their CiA 301 defaults (1000h, 1001h, 1018h, 1005h, 1006h, 100Ch, 100Dh, 1010h, 1011h, 1014h, 1016h, 1017h, 1200h, the four RPDO and four TPDO records); PDO records read-only on the bus unless WritableCommunicationParameters; sub-index 04h of the PDO communication records absent (0609 0011h) |
| FR-CO-014 | The object dictionary is the single source of truth: an accepted SDO download and a local write reach the service alike, the configuration methods are OD writes, managed objects cannot be re-declared |
| FR-CO-015 | TPDO transmission types 00h, 01h–F0h, FCh, FDh, FEh/FFh; reserved values rejected with 0609 0030h |
| FR-CO-016 | Inhibit time 1800h:03 (100 µs units, minimum interval) and event timer 1800h:05 (ms, maximum interval) for event-driven TPDOs |
| FR-CO-017 | Synchronous RPDOs (1400h:02 = 00h–F0h): received data actuated with the next SYNC |
| FR-CO-018 | COB-ID control bits valid/RTR/frame in 1400h:01, 1800h:01, 1005h and 1014h; restricted CAN-IDs of Table 40 and 29-bit frames rejected |
| FR-CO-019 | Reset Node / Reset Communication restore the power-on values; 1010h:01 "save" and 1011h:01 "load" (in-memory); wrong signature 0800 0020h |
| FR-CO-020 | Mapping validation: existence, mappability, direction, width, byte alignment, 8-byte limit, sub-index position, sub0 as UNSIGNED8, dummy mapping 0002h–0007h |
| FR-CO-021 | Producer-side life guarding from 100Ch/100Dh with LifeGuardingEvent (occurred / resolved); heartbeat takes precedence |
| FR-CO-022 | NMT state semantics of Table 37: no SDO, SYNC or EMCY in Stopped, PDO only in Operational, state-change heartbeat only with the producer on, guarding toggle reset on reset |
| FR-CO-023 | Heartbeat consumer and producer from 1016h/1017h; 1016h grows per consumer; duplicate node-id 0604 0043h |
| FR-CO-024 | RPDO length handling: too short → not processed and EMCY 8210h; too long → the first bytes are used |
| FR-CO-025 | A node opened from an EDS or DCF (CiA 306, read with EdsDcfNet): application objects with type, access, PDOMapping and value from the file, the managed communication objects through the validated write path, PDO records in the §7.5.2.38 order, undeclared PDOs absent, $NODEID evaluated, a DCF's ParameterValue and NodeID honoured |
| FR-CO-026 | Whatever the node cannot take as written is degraded, corrected in the dictionary and reported per entry in DeviceDescription (omitted / corrected / PDO disabled / not implemented / default supplied, with the SDO abort code that decided) |
| FR-CO-027 | The file's access rights govern the bus: rw PDO records are writable without WritableCommunicationParameters, ro stays ro; its PDOMapping attribute is the object's mappability |
| FR-CO-028 | The described values are the power-on values: "load" and Reset Communication return 1000h–1FFFh to them (or to the last "save"), Reset Node the application objects too |
| FR-CO-029 | The SDO client transfers only what a peer EDS/DCF bound for the server declares; without one, only 1000h:00, 1001h:00 and 1018h:00–04h (SDO client and a peer's device description) |
| FR-CO-030 | ObserveForeignPdoAsync splits a peer's PDO with its live COB-ID and mapping, falling back to the peer file (Observing a peer PDO) |
| FR-CO-031 | NMT flying master election, CiA 302-2 v4.1.0 (Flying master) |
| FR-CO-032 | Boot-up of the slaves in 1F81h by the active master, 1F80h, 1F82h, 1F89h (Boot-up) |
| FR-CO-033 | Listen-only discovery: heartbeat or boot-up within a caller-chosen window, nothing transmitted (Discovery) |
| FR-CO-034 | Scan on request: one SDO upload of 1000h:00 per node-id not already found (Discovery) |
Deliberate omissions, each checked against the norm text:
- TIME (
1012h) and synchronous window length (1007h) — bothCategory: Optional(scope item 10). A synchronous TPDO is transmitted at the SYNC and is never suppressed for a closed window, because there is no window. - Bit-granular PDO mapping — bit lengths must be multiples of 8. Over SDO another length is
rejected with
0607 0010h;PdoMappingEntryrefuses it in its constructor. - MPDO (
sub0=FEh/FFhof a mapping record) — rejected with0609 0030h, like every count above40h. - Non-volatile storage behind
1010h— "save" takes the current communication parameters as the power-on values a reset restores, for the lifetime of the process. A device whose configuration must survive a restart persists it itself and replays it afterOpenNode. - 29-bit COB-IDs — the node sends and receives CAN base frames only; bit 29 of any COB-ID
word is rejected with
0609 0030h, as CiA 301 prescribes for such devices, andConfigureTpdo/ConfigureRpdothrow for it. - The DLC-1 guarding RTR — CiA 301 Figure 44 draws the node-guarding request with DLC 1. CanKit derives a frame's DLC from its data and refuses data on a remote frame, so the RTR this node sends carries DLC 0 (#59). Producers answer a guarding RTR by CAN-ID; what the consumer evaluates is the reply.
- Reset Node vs. Reset Communication without a device description — both restore the same
set, the communication-profile objects, because the node then has no source for the power-on
values of the application objects; the application restores those itself from
ApplicationReset, which runs before the boot-up goes out. With a description the two differ as CiA 301 says (see below). - The pst > 0 fallback from block to segmented transfer —
pst = 0is forced, which CiA 301 §7.2.4.3.13 defines as "change of transfer protocol not allowed". What a device description declares beyond this — a1012h, a 24-bit integer, a fifth PDO — is not built either; Device descriptions says what the node does with such an entry instead of ignoring it.
OpenNode creates the CiA 301 communication profile in the node's object dictionary, and the
flying-master objects, at their defaults: 1000h (device type 0), 1001h (error register), 1018h (sub0 = 01h,
vendor-id 0 = "no vendor-ID assigned"), 1005h/1006h (SYNC on 080h, not generated),
100Ch/100Dh (life guarding off), 1010h/1011h (store / restore), 1014h (EMCY on
080h + node-id, valid), 1016h/1017h (heartbeat off), 1F80h/1F81h/1F82h/1F89h/1F90h
(flying master off, no slave assigned, boot timeout 0; see Flying master),
1200h (the default SDO server,
constant) and four RPDO plus four TPDO records (1400h–1403h, 1600h–1603h,
1800h–1803h, 1A00h–1A03h) at their pre-defined connection set CAN-IDs, "not valid" until
configured. 1000h and 1018h are placeholders the application replaces with AddU32 — they
stay outside the PDOs: the communication profile area is never a mapping target, whatever an
entry's mappability flag says. The other objects are managed by the node and take values, not
re-declarations — an Add* on one
of them throws InvalidOperationException, whether it would replace a sub-index or add one the
node does not implement (1016h grows through AddHeartbeatConsumer, not by hand).
API calls are OD writes. StartFlyingMaster writes 1F90h:03 and bits 0 and 5 of 1F80h;
StartHeartbeatProducer writes 1017h; AddHeartbeatConsumer
writes a sub-index of 1016h, growing the array when every slot is taken; StartSyncProducer
writes 1006h and bit 30 of 1005h; SendEmcyAsync writes 1001h;
ConfigureTpdo/ConfigureRpdo run the §7.5.2.38 re-mapping procedure over the PDO records.
What a master reads over SDO is therefore what the node does, and a value the node accepts from
a master takes effect the same way.
Both write paths are validated alike. Every write to a managed object — an SDO download or a
local ObjectDictionary.WriteRaw/WriteUnsigned — is checked against the norm's value rules
before it is stored, and the runtime is rebuilt from the dictionary afterwards. A rejected SDO
download is answered with the CiA 301 abort code; a rejected local write throws
ArgumentException naming the same code. The codes in use:
| Abort code | Raised for |
|---|---|
0601 0000h |
Writing a mapping entry while the mapping is enabled (sub0 ≠ 0) |
0601 0002h |
Download to a read-only object: the PDO records without WritableCommunicationParameters, 1200h, 1000h, 1001h, 1018h, 1F81h:00, 1F82h:00, 1F90h:00 |
0602 0000h |
Mapping entry whose target object does not exist; sub0 enabling a mapping with an empty slot |
0604 0041h |
Target not PDO-mappable, not accessible in the PDO's direction, or of another width than mapped; dummy entry with the wrong width |
0604 0042h |
Mapping longer than 8 bytes |
0604 0043h |
Second 1016h entry for a node-id already monitored |
0607 0010h |
Mapping bit length 0, above 64 or not a multiple of 8 |
0607 0012h / 0607 0013h |
Download of the wrong width for the object — sub0 of a mapping record is UNSIGNED8 |
0609 0011h |
Sub-index 04h of a PDO communication record, or any other absent sub-index |
0609 0030h |
Reserved transmission type; bit 29 (frame) set; bit 30 of 1014h set; restricted CAN-ID; CAN-ID changed while the object is valid; inhibit time changed while the PDO is valid; mapping count above 40h; flying-master priority above 2; device time slot 0; a priority time slot that is not greater than 127 times the device time slot; a 1F82h request that is not a known NMT state, or that names a node which is not assigned |
0800 0020h |
Wrong signature written to 1010h:01 / 1011h:01 |
0800 0022h |
SDO server session open when the node entered Stopped or was reset; 1F82h written while this node is not the active NMT master |
Read-only by default. CanOpenNodeOptions.WritableCommunicationParameters (default
false) decides whether a master may write the PDO communication and mapping records over SDO.
Off, the records are ro on the bus: the application configures its PDOs through
ConfigureTpdo/ConfigureRpdo, and the records describe that configuration truthfully without
offering to change it. CiA 301 permits exactly this for PDO parameter records — footnote * of
its objects overview: "These may be ro" — and for nothing else, which is why the SYNC, EMCY,
heartbeat and guarding objects are rw in either case, as their definitions prescribe, and
1200h is constant. On, every accepted write takes effect in the PDO engine.
Power-on values. An NMT Reset Node or Reset Communication returns the managed objects to
their power-on values: the values last stored, or the defaults the node was created with if
nothing was stored. StoreParameters() is the local equivalent of a master writing "save" to
1010h:01; RestoreDefaultParameters() of "load" to 1011h:01 — per §7.5.2.14 the defaults
become valid with the next reset, not before. A device configured through
ConfigureTpdo/ConfigureRpdo calls StoreParameters() once its configuration is complete;
otherwise a Reset Communication from the master undoes it.
Units are the norm's: 1006h in µs; 1016h, 1017h and 100Ch in ms; 1800h:03 in
multiples of 100 µs; 1800h:05 in ms. The TimeSpan arguments of the API are converted and
range-checked against the object's UNSIGNED width.
A node can be opened from a CiA 306 device description — an EDS, or a DCF for a
commissioned device — read with EdsDcfNet:
var description = CanOpenDeviceDescription.Load("device.eds");
using var device = CanOpen.OpenNode(bus, nodeId: 0x11, description);
// A DCF carries its NodeID, so the node-id may be left to the file:
using var commissioned = CanOpen.OpenNode(bus, CanOpenDeviceDescription.Load("device.dcf"));
foreach (var finding in device.DeviceDescription!.Findings)
Console.WriteLine(finding); // what the node could not take as writtenThe description shapes the object dictionary, and the dictionary drives the node as it always does, so a master sees the described device from its first frame:
- Application objects are created with the file's data type, access,
PDOMappingattribute and value — a DCF'sParameterValueover theDefaultValueof the EDS it was made from.$NODEID+…expressions are evaluated against the node-id the node is opened with. - The managed communication objects (
1000h,1001h,1005h,1006h,100Ch,100Dh,1014h,1016h,1017h,1018h,1F80h,1F81h,1F89h,1F90h) take the file's access and value through the same validated write path an SDO download uses, so a value the norm would reject on the bus is rejected here too. A mandatory object the file omits keeps its placeholder.1F82his the exception: a described value is stored as the initial tracked NMT state and is not sent as a command. - PDO records are applied in the order of §7.5.2.38 — communication parameters with the PDO
destroyed, then the mapping, then "create PDO" — and a PDO the file does not declare does not
exist (
0602 0000h). The file's access rights on the records hold on the bus: a record it declaresrwis writable by a master withoutWritableCommunicationParameters, one it declaresrois not. - The described values are the power-on values. "Load" (
1011h) and Reset Communication return1000h–1FFFhto them, or to the last "save"; Reset Node restores the application objects as well, which without a description the node cannot do.
What the node cannot implement as written is degraded, corrected in the dictionary and
reported — never ignored. ICanOpenNode.DeviceDescription is a DeviceDescriptionReport:
IsExact is true when every entry was taken as written, and each DeviceDescriptionFinding
names the entry, the outcome, the reason, the described value and, where an SDO rule decided,
its abort code:
| Outcome | Meaning |
|---|---|
Omitted |
Not created: a data type the dictionary does not represent (24/40/48/56-bit integers), a fifth PDO, an unparsable value, a sub-index of a fixed record the node does not implement |
Corrected |
Created with the CiA 301 default instead of the described value, which the rule an SDO download hits rejected (a reserved transmission type, a second 1016h entry for one producer) |
PdoDisabled |
The PDO stays destroyed (bit 31 set) because its communication record or its mapping could not be applied — a 29-bit COB-ID, a mapping onto an object the file does not declare |
NotImplemented |
Created with the described value as data, with no behaviour behind it: 1012h (TIME), a 1200h that differs from the default SDO server |
SuppliedDefault |
A mandatory object (1000h, 1001h, 1018h) the file does not declare; the node keeps its placeholder |
The parsed model is available as CanOpenDeviceDescription.Objects (the EdsDcfNet object
dictionary) and DeviceInfo, and ParseDiagnostics lists what the parser repaired while reading
a lenient file. This loader shapes the node it is given to. A foreign device's file is bound
with BindPeerDeviceDescription instead, as the list of objects the SDO client may transfer
(below). Writing a whole DCF into a foreign device
is not built.
SdoUploadAsync and SdoDownloadAsync do not accept an arbitrary index. Before any frame is
sent they check a peer EDS or DCF bound for that server:
var peer = CanOpenDeviceDescription.Load("remote.eds");
master.BindPeerDeviceDescription(nodeId: 0x11, peer);
await master.SdoUploadAsync(0x11, 0x2000, 0x00); // only if 2000h:00 is in remote.edsA DCF is commissioned for one node-id. Binding it to a different node throws
ArgumentException and leaves any description already bound for that node in place. An EDS
has no commissioned node-id and may be bound to any server.
CanOpenDeviceDescription.Contains is that check. A pair the file does not declare throws
PeerSdoAccessException (PeerDescriptionLoaded is true), including 1000h, 1001h and
1018h when the file leaves them out. UnbindPeerDeviceDescription drops the binding.
With no description bound for the server, only the three CiA 301 mandatory base objects are transferred:
| Object | Sub-indices allowed without a peer file |
|---|---|
1000h Device type |
00h |
1001h Error register |
00h |
1018h Identity |
00h–04h (vendor-id, product code, revision, serial number) |
1018h:05 and above are not part of that exemption, and neither is any optional object,
including 1003h. PeerSdoAccessException.IsAllowedWithoutPeerDescription is that list.
Anything else throws PeerSdoAccessException with PeerDescriptionLoaded false, again before
a frame is sent.
A request the bus rejects or does not confirm fails its transfer immediately with
CanOpenTransportException, instead of waiting for SdoTimeout. With the request not on the
wire, no answer can come, and a timeout would look like a silent server. The same failure is also
raised on BackgroundExceptionOccurred. If SdoTimeout elapses while a request is still waiting for its
confirmation, the confirmation decides. A failed send ends the transfer with
CanOpenTransportException. A confirmed one lets the timeout stand, which then completes up to
the confirmation window (CanBusService.DefaultConfirmTimeout) later than SdoTimeout. Only the latest request of a transfer can decide it this way. The client sends again only
after the server has answered, and that answer proves the earlier request reached the server.
A later failed confirmation of the earlier request means a lost echo, not a lost frame, and is
only raised on BackgroundExceptionOccurred. A send that is cancelled because the service was
disposed counts as failed.
ObserveForeignPdoAsync splits a PDO of another node into a caller-supplied sink. Nothing is
written to this node's object dictionary, and a frame that is not one of this node's own RPDOs
is still not applied when it arrives.
The COB-ID is read live from 1400h:01 / 1800h:01. A word that comes back is used ahead of the
peer EDS or DCF, including a PDO the device marks invalid (bit 31) and a CAN-ID the file does
not name. The same entry in the file is used only when that upload aborts, times out, is refused
by the peer-SDO gate, finds another SDO already in flight, or does not return a word.
The mapping is read live from 1600h–1603h / 1A00h–1A03h through the same SDO client, so
the peer description bound for that node applies. A read that aborts, times out, is refused by
that gate, or is not a mapping of at most eight byte-aligned entries uses the mapping in the
file passed to the call. Observations of one peer run one after another. A CAN-ID CiA 301
restricts (CanOpenCobId.IsRestricted) is not taken as a PDO, from the device or from the file.
A payload shorter than the mapping writes nothing; a dummy entry 0002h–0007h consumes its
bytes and writes no signal.
CanOpenDiscovery finds the nodes on a bus. Listening is the default, and scanning is a separate
call.
// Opens no node and transmits nothing. The window defaults to 2 s.
var heard = await CanOpenDiscovery.ListenAsync(bus);
// Only when asked: one SDO upload of 1000h:00 to each node-id not heard.
using var client = CanOpen.OpenNode(bus, nodeId: 0x7F);
var answered = await CanOpenDiscovery.ScanAsync(client, heard.Select(n => n.NodeId));ListenAsync subscribes to 701h–77Fh for the window the caller passes, any positive duration.
Without one it uses DefaultListenWindow, 2 seconds. A node is reported when either of these is
seen:
- a heartbeat: state byte
04h,05hor7Fh - a boot-up:
00h
Evidence says which of the two were heard. HeartbeatState is the state in the last heartbeat.
Bit 7, the guarding toggle, is masked, as the node's own heartbeat consumer does. Other state
bytes, remote frames and 29-bit frames are ignored. There is an overload that takes
an ICanBusService shared with other protocols, and that service is left open.
ScanAsync reads 1000h:00 through the client's SDO client, and that is the only request it
sends. A node that stays silent also gets the client's usual timeout abort (0504 0000h), so a
node that answers late can drop its half-open transfer. It asks every node-id from 1 to 127
except the client's own and the ones passed in, and the requests run concurrently. A node is
reported when it answers:
- with the value, which lands in
DeviceType - with an SDO abort
- with a response the client aborts as malformed
The client's own timeout (SdoTimeout) means the node is absent. The peer-SDO gate applies, so a
node-id whose bound file does not list 1000h:00 is not asked. Neither is a node-id the client
already has a transfer with, and neither kind is reported. Any other failure ends the scan. That
includes a request the bus rejects or does not confirm, which fails with
CanOpenTransportException, so a dead bus does not look like an empty one.
Opening the client transmits its boot-up, which is one reason the scan is not the default.
Every TPDO and RPDO is rebuilt from its communication and mapping records whenever one of them
changes. The transmission type byte in 1800h:02 decides how a TPDO fires:
TpdoTransmission |
1800h:02 |
Behaviour |
|---|---|---|
SynchronousAcyclic |
00h |
A change of state of a mapped object or TriggerTpdoAsync latches one transmission for the next SYNC |
Synchronous |
01h |
Transmitted at every SYNC |
| (write the byte) | 02h–F0h |
Transmitted at every n-th SYNC. CanOpenTransmissionType.SynchronousEveryNthSync(n) gives the byte; write it to 1800h:02 through the object dictionary |
RtrOnlySynchronous |
FCh |
Sampled at every SYNC into a buffer; the buffered sample answers an RTR — nothing answers before the first SYNC |
RtrOnlyEventDriven |
FDh |
Sampled and transmitted when an RTR arrives |
EventDriven, EventTimer |
FEh |
Transmitted on change of state and on TriggerTpdoAsync, rate-limited by the inhibit time; EventTimer additionally sets 1800h:05 |
| (write the byte) | FFh |
As FEh |
F1h–FBh are reserved and rejected with 0609 0030h. A TPDO fires only in Operational, and
every SYNC counter, latch and sample starts afresh on each transition into Operational.
Inhibit time and event timer apply to FEh/FFh only, as §7.5.2.37 says. The inhibit time
(1800h:03) is the minimum interval between two transmissions: an event inside it schedules
exactly one transmission for when it has elapsed, and further events until then coalesce into
that one. The event timer (1800h:05) is the maximum interval: it restarts with every
transmission and fires only in Operational. 1800h:03 can be changed only while the PDO is
invalid (bit 31 set). Both are measured on the actor's ITimeSource, so a test can drive them
with a virtual clock.
RTR. A remote frame on a valid TPDO's COB-ID is a PDO read (§7.2.2.5.2) and is answered
unless bit 30 of 1800h:01 ("no RTR allowed") is set — by the event-driven types and the
RTR-only types (see the table). A synchronous TPDO (00h–F0h) is transmitted after the SYNC
and by nothing else, so its RTR is not answered: §7.2.2.3 defines the remote request for
event-driven PDOs. The record's power-on default carries bit 30; ConfigureTpdo without an
explicit cobId clears it.
PDOs sharing a COB-ID. Two valid RPDO records may name the same COB-ID, and a frame on it
actuates each of them; two valid TPDO records may as well, and an RTR on it reads each of them.
SYNC is the exception: a frame on the CAN-ID in 1005h is a SYNC and nothing else, so 1005h
cannot be moved onto the CAN-ID of a PDO that exists or of a valid EMCY, and neither a PDO nor
a valid EMCY can be put on the SYNC CAN-ID (0609 0030h either way).
Synchronous RPDOs. ConfigureRpdo(…, transmission: RpdoTransmission.Synchronous) writes
00h to 1400h:02: received data is held and written to the object dictionary — and reported
through RpdoReceived — with the next SYNC, the most recent frame winning. EventDriven
(FEh) actuates immediately. An RPDO shorter than its mapping is not processed and produces
EMCY 8210h (if 1014h is valid); a longer one is read up to the mapped length.
Mapping validation runs on every entry, whether it arrives over SDO or through
ConfigureTpdo/ConfigureRpdo: the target must exist, be PDO-mappable (pdoMappable on the
Add* methods — default true for application objects, false for the communication
profile), be readable for a TPDO or writable for an RPDO, and have the mapped width; the total
must fit 8 bytes; entries occupy the payload in sub-index order. A dummy entry — index
0002h–0007h, sub-index 0, the bit length of that static data type — occupies its bytes
without touching the dictionary, which pads an RPDO to a peer's layout. ConfigureTpdo and
ConfigureRpdo copy the PdoMapping into the record; mutating the instance afterwards changes
nothing. A rejection throws ArgumentException naming the abort code and leaves the PDO
invalid.
Re-mapping from a master follows §7.5.2.36/§7.5.2.38 and needs
WritableCommunicationParameters: destroy the PDO (bit 31 of 1800h:01 to 1) → disable the
mapping (1A00h:00 = 0) → write the entries (1A00h:01…) → enable the mapping (1A00h:00 = N)
→ set transmission type, inhibit time, event timer → create the PDO (bit 31 to 0). The engine
picks the record up at the last step.
Heartbeat. The producer and the consumer are separate modules. HeartbeatProducer sends
this node's heartbeat; HeartbeatConsumer watches other nodes. Neither module references the
other. 1017h ≠ 0 runs the producer. Each sub-index of 1016h with a node-id in 1..127 and a
non-zero time is a consumer whose timeout is armed immediately, so a producer that never
appears is reported too. HeartbeatReceived reports every heartbeat and boot-up on
0x700 + id.
HeartbeatTimeout, NodeGuardingTimeout and EmcyReceived share the node's event queue
with heartbeats, SYNC, PDOs and NMT, and keep their place in that order. They are the events
the queue will not drop when a subscriber falls behind EventQueueCapacity (default 64). A
slow handler can lose a heartbeat or an RPDO; it cannot lose a timeout or an emergency and
then treat the peer as healthy. Their backlog is still bounded: a timeout for a producer whose
timeout is already waiting is folded into it, an EMCY identical to one already waiting likewise
(only while nothing else about that producer was queued in between: error, reset, error stays
three events), and a producer with EventQueueCapacity distinct emergencies waiting has its further ones
discarded, with one BackgroundExceptionOccurred report per burst. BackgroundExceptionOccurred
is not queued.
Node guarding (consumer side). StartNodeGuardingConsumer(nodeId, guardTime, lifeTimeFactor)
polls 0x700 + nodeId with an RTR every guardTime and raises NodeGuardingTimeout after
guardTime × lifeTimeFactor without a reply whose toggle bit alternated. A boot-up from the
producer resets the toggle baseline rather than counting as a reply.
Life guarding (producer side). With 100Ch (guard time) and 100Dh (life time factor)
both non-zero, the node supervises the master that guards it: guarding starts with the first
RTR received, and when the next RTR stays away longer than guard time × life time factor,
LifeGuardingEvent reports Occurred — once per lapse — and the next RTR reports Resolved.
Precedence. CiA 301 §7.2.8.3.2.2 forbids running both protocols on one NMT slave: while
1017h ≠ 0 the heartbeat protocol is in use, guarding RTRs are not answered and no life
guarding runs (RespondToNodeGuardingRtr additionally gates the reply). The same rule decides
the state-change heartbeat: a heartbeat announcing a new NMT state goes out only while the
producer is on, because with it off an unsolicited data frame on 0x700 + id is
indistinguishable from a toggle-0 guarding reply (#43).
Table 37 of CiA 301, as the node applies it:
| Pre-operational | Operational | Stopped | |
|---|---|---|---|
| PDO | — | transmitted and received | — |
| SDO | served | served | requests ignored; an open server session is aborted with 0800 0022h |
| SYNC | consumed and produced | consumed and produced | neither; the producer keeps its cycle and resumes afterwards (SendSyncAsync is a raw send and transmits regardless of the state and of bit 30 of 1005h) |
| EMCY | transmitted | transmitted | held; the most recent one is transmitted when the node leaves Stopped |
| Heartbeat, guarding | active | active | active |
Reset Node and Reset Communication abort SDO server sessions, discard a held EMCY, reset the
guarding toggle to 0 (§7.2.8.3.2.1), restore the power-on values of the communication profile
(see above), raise ApplicationReset, send the boot-up and settle in Pre-operational. With the
heartbeat producer on, a heartbeat with the new state follows the boot-up.
ApplicationReset is the application's turn in the reset: it is raised synchronously on the
actor loop, still in Initialisation, and the boot-up goes out only when the handler has
returned — so a master that reacts to the boot-up never reads an application object the reset
had not reached. NmtCommandReceived is the notification of the command, delivered on the event
queue, and can arrive after the boot-up. A handler of ApplicationReset writes the dictionary
and returns; it must not wait for the node.
SdoDownloadAsync auto-selects the block codec when the payload reaches
CanOpenNodeOptions.SdoBlockThresholdBytes (default 128 bytes). Below that threshold the
payload length picks the codec, per CiA 301: 1..4 bytes go expedited, 5 bytes and up go
segmented. SdoUploadAsync keeps the classic client under SdoTransferMode.Auto (the size is
unknown up front, so the server's initiate response decides expedited vs. segmented); pass
SdoTransferMode.Block to force block upload. Block is the only transport a caller can
force — the expedited/segmented split is not selectable. The block size advertised by this node is
CanOpenNodeOptions.SdoBlockSize (default 127; peers with a smaller window renegotiate
downward). CRC-16/XMODEM is exchanged when both endpoints set the "cc" / "sc" bit
(SdoBlockCrcSupported, default true). Classic segmented transfers carry a server-side idle
timeout (CanOpenNodeOptions.SdoServerTimeout, default 5 s) matching the block transfer guard,
and block transfer retransmits from the first unconfirmed segment on a partial sub-block ACK
(bounded by CanOpenNodeOptions.SdoBlockMaxRetransmissions, default 3).
A master application joins the NMT flying-master election with StartFlyingMaster. The binding
is CiA 302-2 version 4.1.0, network management and NMT flying master, object 1F90h.
Lely's standards index lists that part,
and its NMT master cites the same edition. DSP 302 clause 5.5 is the historical ancestor of
those services, not the binding. CiA 302 is members-only and is not in this repository; the
behaviour below follows the public descriptions of that edition. The maintainer decisions
for the points those descriptions do not settle are on the pull request.
1F80h bit 0 marks an NMT-master-capable device and bit 5 selects the flying-master process.
Both are required. StartFlyingMaster sets them and writes the priority level (0 highest, 2
lowest) to 1F90h:03. The other bits of 1F80h belong to the boot-up below. Clearing bit 0
or bit 5, or calling StopFlyingMaster, leaves the election and stops the boot-up.
1F90h is an array of six UNSIGNED16 values, in milliseconds:
| Sub-index | Meaning | Default |
|---|---|---|
01h |
How long to wait for an active master to answer | 100 |
02h |
Delay before that question is asked | 500 |
03h |
Priority level, 0..2 | 2 |
04h |
Priority time slot | 1500 |
05h |
Device time slot | 10 |
06h |
While active, repeat the negotiation trigger after this long; 0 disables the repeat | 4000 + 10 × node-id |
04h must be greater than 127 times 05h, so a better priority level always finishes before a
worse one, whatever the node-ids are. The defaults satisfy that (1500 > 127 × 10). A priority
above 2, a device time slot of 0, or a pair that breaks the rule is rejected with 0609 0030h.
Sub-index 00h is constant 6 (0601 0002h).
The services are fixed CAN-IDs, which CiA 301 reserves and which this node therefore subscribes
to separately from the 080h–77Fh range:
| CAN-ID | DLC | Meaning |
|---|---|---|
0x073 |
0 | "Is an NMT master already active?" |
0x071 |
2 | The answer, and the claim: priority, then node-id |
0x072 |
0 | Start (or restart) the timeslot race |
0x076 |
0 | Force a new election |
After the delay the node asks with 0x073. A reply whose priority number is lower than or
equal to ours — a better master, or an equal one — puts this node on standby. An equal claim
does not depose a master that is already active; during the race the first claim of equal
priority wins, and the timeslot formula makes that the lower node-id
(priority × 04h + node-id × 05h). A reply from a worse master is a reason to send 0x076 and
start again. A claim from a worse node during the race is treated as a network configuration
error: 0x076 goes out and FlyingMasterChanged reports ConfigurationError.
The first election after StartFlyingMaster is a cold boot. If nobody answers, the node
broadcasts NMT Reset Communication (0x82, node 0) and runs the election again as a warm boot,
so the timeslot race is not racing traffic from before the reset. That reset restores power-on
values, which is why StartFlyingMaster records 1F80h, 1F81h, 1F89h and 1F90h as
power-on values before it starts. Call StoreParameters first if the rest of the configuration
must survive the same reset. A later loss of the active master starts a warm election and does
not broadcast Reset Communication again. The active master is who sends Reset Communication when
it receives 0x076. Winning then runs the boot-up below.
The flying master composes those two modules and does not keep a heartbeat timer of its own.
While this node stands by, StartFlyingMaster's heartbeat timeout is installed on the consumer
as a 1016h entry for the winner, unless the application already monitors that node. A timeout
from that module raises ActiveMasterLost and starts a new election. When this node becomes
the active master and 1017h is 0, the producer module is started at half that timeout so
peers can see the loss. Writing the bits of 1F80h without calling StartFlyingMaster runs
the same election and does not invent a heartbeat time; a standby node then reclaims only if
some consumer for the winner times out. FlyingMasterRole is Inactive, Delaying, Detecting, Negotiating, Active
or Standby. ActiveFlyingMasterNodeId names the winner once one is known.
Once FlyingMasterRole is Active, this node initialises the slaves named in 1F81h.
Sub-index 0 is constant 127. Each other sub-index is the node-id, an UNSIGNED32:
| Bit | Set means |
|---|---|
| 0 | This node-id is a slave. The sub-index equal to the master's own id is ignored. |
| 2 | The master may boot it: after it is seen, NMT Start is sent. |
| 3 | Mandatory. The master does not finish boot-up until this slave has been seen. |
| 4 | Keep-alive. NMT Reset Communication is not sent to this slave. |
Bits 8–31 hold the guard time and life time. They are stored. Heartbeat is preferred, and
node guarding is not started from them. 1F84h–1F88h (the identity check) and the concise
DCF are not implemented.
1F80h, read the way the open stack that cites 302-2 v4.1.0 reads it:
| Bit | Clear | Set |
|---|---|---|
| 1 | Start each slave on its own. | One NMT Start, target 0, after every mandatory slave has been seen. Sent only when bit 2 is also clear, so the master enters Operational together with the slaves. The master does not apply that broadcast to itself. |
| 2 | This node enters Operational when the mandatory slaves have been seen, or at once when there are none. | This node stays in its current NMT state. |
| 3 | Boot the assigned slaves. | Do not reset them and do not send NMT Start. |
| 4 | On a mandatory-slave timeout, reset that slave. | On a mandatory-slave timeout, NMT Reset Node to every assigned slave. |
| 6 | — | On a mandatory-slave timeout, NMT Stop to every assigned slave. Takes precedence over bit 4. |
StartFlyingMaster leaves bits 1, 2, 3, 4 and 6 as they were. Which value that is depends on
the profile the node was opened with (CanOpenNodeOptions.Profile), checked when 1F80h is
created. A device starts with bits 2 and 3 clear: the winner enters Operational, and it starts
slaves individually if 1F81h assigns any. A tool starts with both bits set, so self-start and
slave-start stay suppressed until the application clears them.
Each assigned slave that is not keep-alive is sent NMT Reset Communication to its own node-id. A broadcast is not used there. While this node is the active flying master it ignores NMT addressed to its own node-id, and a broadcast reset or stop does not take the master down (including the echo of a reset it sent itself).
The guard time and life time stored in the upper bytes of each 1F81h entry are kept as
written and are not used to start node guarding. Heartbeat (1016h / 1017h) is the
keep-alive this node runs.
Heartbeats, including the boot-up byte 0x00, update 1F82h at that node-id. Reading 1F82h
returns the last state byte, or 0 when none has been seen. Writing it, while this node is the
active master, requests a command and does not replace that state. The value is the state, not
the command specifier: 4 stop, 5 operational (NMT Start), 6 reset node, 7 reset
communication, 127 pre-operational. Sub-index 80h addresses every node. Sub-index 0 is
constant 128. A write while this node is not the active master is rejected with 0800 0022h.
1F89h is the boot timeout in milliseconds. 0 disables it. When it elapses, each mandatory
slave that was never seen raises FlyingMasterChanged with SlaveBootTimeout, and the boot-up
stops before this node enters Operational and before a simultaneous NMT Start. The error
reaction is the 1F80h bit 6 / bit 4 row above. With both clear, only the missing slave
receives NMT Reset Node.
using CanKit.Core;
using CanKit.Pro.CANopen;
using CanKit.Pro.CANopen.Nmt;
using CanKit.Pro.CANopen.Pdo;
using var bus = CanBus.Open("virtual://demo/0",
cfg => cfg.SetProtocolMode(CanProtocolMode.Can20).Baud(500_000));
using var node = CanOpen.OpenNode(bus, nodeId: 0x11);
// FR-CO-001 / FR-CO-013: the communication profile is already there. Replace the placeholders
// and add the application objects.
node.ObjectDictionary.AddU32(0x1000, 0x00, 0x00030191, OdAccess.ReadOnly); // device type
node.ObjectDictionary.AddU16(0x2000, 0x00, 0x0000); // a process value
// FR-CO-005 / FR-CO-016: TPDO1 carries 2000h, event-driven, not more often than every 10 ms
// and at least every 500 ms. This is a write to 1800h / 1A00h.
node.ConfigureTpdo(1, new PdoMapping().Add(0x2000, 0x00, bitLength: 16),
transmission: TpdoTransmission.EventTimer,
eventTimerInterval: TimeSpan.FromMilliseconds(500),
inhibitTime: TimeSpan.FromMilliseconds(10));
// FR-CO-008: heartbeat producer (1017h) and a consumer for a peer (1016h).
node.StartHeartbeatProducer(TimeSpan.FromMilliseconds(200));
node.AddHeartbeatConsumer(producerNodeId: 0x12, timeout: TimeSpan.FromMilliseconds(500));
node.HeartbeatTimeout += (s, e) => Console.WriteLine($"missed HB from 0x{e.ProducerNodeId:X2}");
// FR-CO-019: make this the configuration a Reset Communication comes back to.
node.StoreParameters();
// FR-CO-002: 1000h:00, 1001h:00 and 1018h:00–04 are readable without a peer file.
var value = await node.SdoUploadAsync(serverNodeId: 0x12, index: 0x1000, subindex: 0x00);
// FR-CO-007: bring the network up as an NMT master (PDOs flow in Operational only).
await node.SendNmtCommandAsync(NmtCommand.Start, targetNodeId: 0);
// Flying master (CiA 302-2 v4.1.0): priority 0 is the highest. The heartbeat
// timeout is how long a standby waits before electing again.
node.StartFlyingMaster(priorityLevel: 0, activeMasterHeartbeatTimeout: TimeSpan.FromMilliseconds(500));See tests/CanKit.Pro.Tests/TestCases/CANopen for end-to-end examples that exercise every FR-CO
requirement over the CanKit.Adapter.Virtual loopback bus.
CanKit.Pro.CANopen/
CanOpen.cs // factory
ICanOpenNode.cs // public contract
CanOpenNode.cs // node core: subscription, dispatch, SDO client + server, NMT, heartbeat
CanOpenNode.CommunicationProfile.cs // partial: the CiA 301 objects, their validation, power-on values, resets
CanOpenNode.Pdo.cs // partial: PDO engine — transmission types, inhibit time, event timer, RTR, mapping
CanOpenNode.SdoBlock.cs // partial: SDO block transfer (client + server)
CanOpenNode.NodeGuarding.cs // partial: node-guarding consumer + producer, life guarding
CanOpenNode.FlyingMaster.cs // partial: NMT flying-master election
CanOpenNode.BootUp.cs // partial: boot-up of the slaves in 1F81h once the master is active
CanOpenNode.PeerSdo.cs // partial: peer EDS/DCF binding and the client SDO gate
CanOpenNode.ForeignPdo.cs // partial: splitting a peer's PDO
CanOpenDiscovery.cs // listen-only discovery and the scan on request
CanOpenNodeOptions.cs
CanOpenCobId.cs // pre-defined connection set, COB-ID control bits, restricted CAN-IDs
CanOpenEvents.cs // event argument types
ObjectDictionary.cs, OdEntry.cs
Nmt/NmtState.cs // NMT enum + command specifier
Nmt/FlyingMaster.cs // flying-master role and signal
Sdo/ // codec, abort codes, exception, block-transfer codec + mode enum
Pdo/PdoMapping.cs // mapping entries, transmission-type enums and byte constants
Emcy/EmcyMessage.cs // 8-byte encode/decode
SdoTransferMode.Expedited and SdoTransferMode.Segmented are gone. SdoTransferMode.Block
keeps its value 3 — the gap the removed members leave behind is deliberate, see below.
Neither removed member ever reached the wire encoder, so below
CanOpenNodeOptions.SdoBlockThresholdBytes dropping the argument sends exactly the same frames.
At or above the threshold it does not — see "One behavioural difference" below, which is the only
case in this migration that changes traffic:
// before — below the block threshold, both of these produced identical traffic
await node.SdoDownloadAsync(id, index, sub, data, mode: SdoTransferMode.Expedited);
await node.SdoDownloadAsync(id, index, sub, data, mode: SdoTransferMode.Segmented);
// after
await node.SdoDownloadAsync(id, index, sub, data);Below CanOpenNodeOptions.SdoBlockThresholdBytes the codec is picked from the payload length,
per CiA 301: 1..4 bytes expedited, 5 and up segmented. The threshold is tested first and so bounds
the expedited range as well — a node configured with a threshold of 1..4 sends even a one-byte
payload by block transfer. On upload it is the server's initiate response that decides. Block
remains, because it is the one transport the client genuinely negotiates rather than derives.
One behavioural difference. At or above CanOpenNodeOptions.SdoBlockThresholdBytes (default
128), a download that used to pass Expedited or Segmented bypassed block transfer as an
undocumented side effect. It now uses block transfer like any other download of that size. If a
peer cannot handle that, raise SdoBlockThresholdBytes on the node options rather than reaching
for a mode argument.
Nothing to remap. Block keeps its numeric value 3, so a persisted or transmitted enum
value still means what it meant. That is why the enum is left with a gap where 1 and 2 used
to be: renumbering Block to 1 would have made it collide with the value Expedited carried
in 1.2.x, and an already-compiled caller passing that literal would have gone from requesting a
no-op hint to forcing block transfer — a silent change on the wire that hangs against a peer with
no block support. Removing the members gives such a caller a compile error instead, which is the
point of the break.
Each item names what a caller does about it.
- PDO records are read-only on the bus by default. A master writing
1400h,1600h,1800hor1A00hover SDO gets0601 0002hunless the node was opened withCanOpenNodeOptions.WritableCommunicationParameters = true. Dynamic mapping over SDO, which 1.2.x offered unconditionally, needs that option now. ConfigureTpdo/ConfigureRpdovalidate the mapping. Every mapped object must exist in the OD, be PDO-mappable, be accessible in the PDO's direction and have the mapped width; a violation throwsArgumentExceptionnaming the abort code and the PDO stays invalid. 1.2.x stored the mapping unchecked and zero-filled a missing object at transmission time. Add the objects before configuring the PDO.EventTimerTPDOs also transmit on change of state. The mode isFEhwith1800h:05, and CiA 301 makes the event timer the maximum interval, not the period; 1.2.x triggered change-of-state forEventDrivenonly. A TPDO that must fire on the timer alone maps objects the application does not write between transmissions, or is opened withEnableChangeOfStateTpdo = false.- The state-change heartbeat needs the producer on. 1.2.x sent a heartbeat on every NMT
transition; now it goes out only while
1017h≠ 0. A consumer that relied on it starts the producer, or uses node guarding. ConfigureRpdogained atransmissionparameter (RpdoTransmission, defaultEventDriven) aftercobId, andConfigureTpdogainedinhibitTimeas its last parameter (default off). Existing calls compile unchanged.PdoMappingis copied at configuration. Mutating the instance afterConfigureTpdo/ConfigureRpdono longer changes the PDO (#42). Call the method again instead.SendEmcyAsyncwrites1001hwith the error register it carries, and the returned task faults withInvalidOperationExceptionwhen EMCY is disabled (bit 31 of1014h). In Stopped the EMCY is held, not sent.- NMT Stopped and resets mean what Table 37 says. In Stopped, SDO requests are ignored, open
server sessions are aborted with
0800 0022h, and SYNC is neither consumed nor produced. Reset Node and Reset Communication restore the communication profile to its power-on values: a configuration made throughConfigureTpdo/ConfigureRpdois gone after a reset unlessStoreParameters()was called. - COB-ID arguments are the CiA 301 words.
cobIdinConfigureTpdo/ConfigureRpdocarries bit 31 (valid) and bit 30 (RTR); 1.2.x passed the value into the frame ID as it was (#41). Bit 29 throws, and so does a restricted CAN-ID of Table 40. - Both roles are served by the same node. There is no separate master or slave type: a node's SDO client, NMT master and consumers work alongside its own object dictionary and PDOs. That was so in 1.2.x too; what is new is that the device side can be configured by a master over the bus.
dotnet add package CanKit.Pro.CANopen
# plus a CanKit adapter for the hardware you actually talk to, e.g.
dotnet add package CanKit.Adapter.Virtual # loopback, no hardwareDependencies: 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.
MIT — see LICENSE. CanKit itself is a separate project licensed under Apache-2.0; see THIRD-PARTY-NOTICES.md.