title: CanKit.Pro.CANopen description: CiA 301 CANopen node: SDO, PDO, NMT, heartbeat, EMCY, object dictionary from an EDS/DCF, CANopen Safety (CiA 304 SRDO/GFC, CiA 319 configuration entity and device), LSS master, slave and commissioning (CiA 305)
L4 · Application CiA 301 CANopen node: SDO, PDO, NMT, heartbeat, EMCY, object dictionary from an EDS/DCF, CANopen Safety (CiA 304 SRDO/GFC, CiA 319 configuration entity and device), LSS master, slave and commissioning (CiA 305)
CanKit.Pro.CANopen¶
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: 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 — CANopen Safety as it reads CiA DSP 304 V1.0 and, for the configuration entity, CiA 319 v2.0.0, and LSS master, slave and commissioning as it reads CiA 305 v3.0.0, 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. The LSS master is also validated against a scripted slave in that suite, not against a third-party LSS slave.
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.
CANopen Safety (CiA DSP 304 V1.0) is available opt-in through node.Safety() — see
CANopen Safety.
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.
Coverage¶
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); FR-CO-035 to FR-CO-046 are CANopen
Safety after CiA DSP 304 V1.0, decided on 9 October 2026
(docs/reviews/2026-10-09-canopen-safety-scope.md). FR-CO-047 is the LSS master after CiA 305
v3.0.0. FR-CO-048 is the LSS slave in the node, including the pending node-id and
LSS_Supported in a device description. FR-CO-049 is commissioning on the master: Fastscan,
node-id assignment, bit timing, store and inquire. FR-CO-050 is CiA 801 automatic bit-rate
detection, the procedure behind table 0 index 9. FR-CO-051 is the configuration entity after CiA 319 v2.0.0 (17 October 2026). FR-CO-052 to FR-CO-057 add the PDO extensions. FR-CO-058 and FR-CO-059 are the device side of CiA 319 (20 October 2026). 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.8–7.2.4.3.16) — client + server, download + upload, blksize negotiation, optional CRC-16/XMODEM |
| FR-CO-005 | TPDO/RPDO mapping held in the mapping records 1600h–17FFh / 1A00h–1BFFh: bit-granular, up to 64 bits, 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, 1007h, 1019h, 100Ch, 100Dh, 1010h, 1011h, 1014h, 1016h, 1017h, 1200h, four RPDO and four TPDO records by default); 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 rejected; PDOs support base and extended frames |
| 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, bit lengths, 64-bit limit, sub-index position, sub0 as UNSIGNED8, dummy mapping 0001h–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, plus the SRDO records up to the file's highest one (CiA DSP 304 §8.4.2.2); 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) |
| FR-CO-035 | Safety objects 1300h, 1301h–1340h, 1381h–13C0h, 13FEh, 13FFh as managed objects at the DSP 304 §8.4.2.2 and EN 50325-5:2010 Table 5 defaults, bus-writable outside Operational, only with SrdoCount > 0 or SRDO records in a description; reset, store and load like the communication objects (The objects) |
| FR-CO-036 | Every write to them validated: value ranges, 0800 0022h in Operational, delete before change, plain/inverted pairs, at most 8 bytes, COB-ID collisions; 13FEh back to 0 on every parameter write (The objects) |
| FR-CO-037 | The 13FFh checksum of §8.4.2.2 as CRC-16/XMODEM over the fields, multi-byte fields little-endian; SrdoCrc is public (Configuring and committing) |
| FR-CO-038 | At the transition to Operational, per SRDO: 13FEh = A5h and a matching checksum, else ConfigurationInvalid and nothing sent (Configuring and committing) |
| FR-CO-039 | Producer: cycle = refresh time, first cycle after 0.5 ms × node-id, plain and inverted frame as one unit, TriggerSrdoAsync and change of state send at once and restart the cycle (Producer) |
| FR-CO-040 | Consumer: order, inversion, SRVT, SCT, length (EMCY 8210h once per run), writing the mapped objects, SrdoReceived / SrdoStateChanged, re-validation by the next valid pair (Consumer) |
| FR-CO-041 | Global failsafe command: sent and reported only in Operational, DLC 0; 1300h records it (GFC) |
| FR-CO-042 | ICanOpenSafety through node.Safety(), without widening ICanOpenNode; configuration, delete, commit, state, options (Opening a safety node) |
| FR-CO-043 | EDS/DCF: the safety objects loaded like the PDO records, with findings; the file's highest SRDO record raises SrdoCount (Opening a safety node) |
| FR-CO-044 | ConfigurePeerSafetyAsync after §9.2 (write, read back, compare, A5h only without a difference) and VerifyPeerSafetyConfigurationAsync after §8.3.1 step D, both through the peer-SDO gate (Configuring a peer) |
| FR-CO-045 | Step D in the boot-up: a DCF-bound safety slave is verified before NMT Start, and no Start goes to node 0 while one is assigned; a failure signals SlaveSafetyConfigurationInvalid and the slave is not started; a mandatory slave or a simultaneous start halts the boot (Verifying, and the boot-up) |
| FR-CO-046 | ObserveForeignSrdoAsync: live record before file, pair check, split into an IForeignPdoSink as ForeignPdoKind.Srdo, no timing judged (Observing a peer SRDO) |
| FR-CO-047 | LSS master (CiA 305): switch state global and selective, configure node-id and bit timing, activate with both switch delays, store, inquire identity and node-id, identify remote and non-configured slaves, Fastscan (Layer setting services) |
| FR-CO-048 | LSS slave in the node: waiting and configuration, identity from 1018h, pending node-id applied on NMT reset communication, unconfigured FFh does not boot, store through ILssConfigurationStore, LSS_Supported in an EDS/DCF (Layer setting services) |
| FR-CO-049 | LSS commissioning on the master: Fastscan of unconfigured slaves, batch and single node-id assignment, bit timing, store, inquire, and 1018h of configured nodes (Layer setting services) |
| FR-CO-050 | CiA 801 automatic bit-rate detection: listen at each candidate in listen-only mode, a received frame confirms the rate, errors or a degraded bus state rule it out, the table is walked over the passes allowed (Automatic bit-rate detection) |
| FR-CO-051 | ConfigurePeerSafetyAsync as the configuration entity of CiA 319 v2.0.0: the identity of §5.2 first, the steps of §5.3 with the storage of step 5 (PeerSafetyConfiguration.Storage, PeerSafetyResult.Stored), and the §6.2 default for the SRVT of a tx SRDO the peer does not implement, in the verification too (Configuring a peer) |
| FR-CO-052 | SYNC counter (1019h), producer wrap/reset and one-time TPDO start gate (1800h:06) |
| FR-CO-053 | Synchronous window (1007h) for RPDO acceptance and TPDO driver hand-off |
| FR-CO-054 | RPDO deadline (1400h:05), one-shot application indication and EMCY 8250h |
| FR-CO-055 | Bit mapping, one-bit BOOLEAN/dummy and native 24/40/48/56-bit integer widths |
| FR-CO-056 | Configurable PDO count up to 512 per direction and 29-bit PDO identifiers |
| FR-CO-058 | The device side of CiA 319: at the write of A5h the checksums of the existing SRDOs are compared and a mismatch sets 13FEh back to 00h at once (§5.3 step 4); the same after a reset, before the boot-up (§5.4) (Configuring and committing) |
| FR-CO-059 | SrdoConfigurationCheckInterval: the same check repeated periodically (§5.3, opt-in); a mismatch in Operational makes every SRDO ConfigurationInvalid (Configuring and committing) |
| FR-CO-057 | SAM/DAM MPDO, scanner/dispatcher blocks and inhibit queue |
| FR-CO-062 | Pre-defined error field 1003h, opt-in by ErrorHistoryCapacity or a description that declares it (Error control) |
Not built, and why¶
Deliberate omissions, each checked against the norm text:
- TIME (
1012h) —Category: Optional; no clock service is implemented behind it. - SRDO mapping of objects without a fixed width — a
VISIBLE_STRING,OCTET_STRING,UNICODE_STRINGorDOMAINcannot be mapped into an SRDO (0604 0041h; a finding for a device description; refused before the first frame of a peer configuration). DSP 304 has no such clause; it is this implementation's rule: safety process data has a fixed width, and the width is what the mapping entry and the checksum pin. A PDO may still map them. - 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 SYNC, EMCY and SRDO COB-IDs — these services remain base-frame-only; PDO communication records support extended identifiers through bit 29.
- 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". - Diverse redundancy and the safe state (CiA DSP 304 §9.5, §8.3.2) — this library is not developed to IEC 61508 / DIN V VDE 0801 and claims no safety integrity level. It builds the inverted frame from the plain one and compares bit by bit on reception; building the pair "by two different ways" and entering the safe state are the device's.
- SRDO details V1.0 leaves open — the checksum's initial value (EN 50325-5:2010 §6.4.1.5 gives
0000h), its byte order (EN 50325-5:2010 formula (2), low byte first: CRC-16/XMODEM with
multi-byte fields little-endian, as CANopenNode feeds them) and the
ro/rwconflict of13FFh(EN 50325-5:2010 Table 11:rwin Pre-operational,roin Operational — the records' rule, which this implementation applies; a file that declares13FFh:nroorconstis honoured, #289). - Bit-granular SRDO mapping and more than 8 objects per SRDO — the safety mapping retains its own narrower limits; the PDO extensions do not widen them.
- A separate object for an SRDO's inverted data — the even mapping slot must repeat the odd one and the inverted frame is built from the plain one. EN 50325-5:2010 Table 8 does not require the repetition. A device may refuse the other form, but as tool and monitor this implementation also refuses a device that uses it (#506).
- The safety application layer of CiA 319 clauses 7 and 8 — the Safety application
configuration valid and … signature objects a CiA profile or a manufacturer-specific profile
places in its own area, and the data set they sign. Which objects, in which order, is the
profile's to say, and no profile with safety provisions is implemented here. What they share
with the SRDO signature is the polynomial:
SrdoCrc.Crc16Xmodem, suitable for up to 2048 bytes, with the choices V1.0 leaves open (#289). - Timing in
ObserveForeignSrdoAsync— the caller holds the timestamps and judges SRVT and SCT. - Automatic bit-rate detection as an LSS command — table 0 index 9 names CiA 801 detection,
but configure bit timing cannot make a device run it: the master only sends the index, and the
slave here rejects index 9 (
BitTimingNotSupported), because it cannot reopen its own channel at another rate on command. The CiA 801 procedure itself is built as a separate call,LssBitRateDetection, which a device runs at startup before it has a node-id. - CiA 801 single-dominant-bit timing (§5.2.2), the external AND/OR-gate variant (§5.2.4), and
the default fallback bit rate (§5.1) — the first needs a timer on the receive line that no
adapter exposes, the second is hardware, and the third is the approach the report rejects.
LssBitRateDetectionis the listen-only method of §5.2.3 only. - Switching the open channel's bit rate —
ActivateBitTimingAsyncwaits both switch-delay periods and, when the caller names a rate and setsLssMasterOptions.BitRateSwitch, calls that callback between them. The callback must retune the channel this master is already reading; it must not dispose that bus. The second delay, and the waiting framereturnToWaitingthen sends after both periods, still go out on it. An adapter that cannot change rate while open is left alone: omitreturnToWaiting, dispose this master after the call returns, and send switch state global from a new master on the new bus. - Switch state global (waiting) inside the first switch delay — CiA 305 §6.4.4 and Figure 15
keep the master and every slave silent until the second
switch_delayhas elapsed. With a host switch (bitsPerSecondandBitRateSwitch)returnToWaitingkeeps to that: waiting goes out after both periods, on the new rate. The slaves switch whatever the caller does, so once the activation is confirmed the master follows through on its own lifetime: a caller that cancels gets control back at once, the host still switches and sends waiting, and later commands, the commissioning release among them, wait for that. Without a host switch the master would be left on the old rate, so a positive delay sends waiting before the first period ends instead — a deliberate departure from §6.4.4. If its confirmation comes back at or after the first deadline, the call fails after both periods. 2.0 is planned to require a host switch forreturnToWaitingwith a delay; the conforming alternative today is the one in the previous item.
What a device description declares beyond this — a TIME service behind 1012h —
is not built either; Device descriptions says what the node does
with such an entry instead of ignoring it. The same holds for the EMCY inhibit time (1015h) and
the error behaviour object (1029h): the node neither maintains nor reads them, and a description
that declares one gets it created as data and reported NotImplemented.
The object dictionary is the configuration¶
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: those objects are not mapping targets, whatever an entry's flag says.
Other communication objects are mappable where CiA 301 explicitly permits it, as described below. 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 — for a PDO, checked when sub0 enables it, over the entries it names |
0604 0043h |
Second 1016h entry for a node-id already monitored |
0607 0010h |
Mapping bit length 0 or above 64 |
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 set on SYNC/EMCY; bit 30 of 1014h set; restricted CAN-ID; CAN-ID changed while the object is valid; inhibit time or SYNC start changed while the PDO is valid; reserved mapping count 41h–FDh; 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 |
0800 0024h |
Upload of an entry of 1003h above its count |
Expedited download without a size. CiA 301 §7.2.4.3.3 permits e = 1, s = 0 (command byte
22h): "d contains unspecified number of bytes to be downloaded". The server then takes the
object's own width: a fixed-width object of 1..4 bytes gets the first bytes of d, an object
without a fixed width (a string or a DOMAIN) gets all four, and an object wider than four bytes,
which an expedited frame cannot carry, is refused with 0607 0013h. The client side does not
apply a width: for an expedited upload response without a size (§7.2.4.3.6, "e = 1, s = 0: d
contains unspecified number of bytes to be uploaded") SdoUploadAsync returns all four bytes.
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.
Device descriptions (EDS/DCF)¶
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 written
The 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, 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 — an invalid 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.
SDO client and a peer's device description¶
One transfer per server is in flight at a time: a call to a server with a transfer in flight
fails with InvalidOperationException ("already in flight") before anything is sent. A safety
transaction with that server (ConfigurePeerSafetyAsync, VerifyPeerSafetyConfigurationAsync)
counts as a transfer in flight for its whole length, and so does the live snapshot of
ObserveForeignPdoAsync (Observing a peer PDO) and of
ObserveForeignSrdoAsync (Observing a peer SRDO). Calls to different
servers are independent.
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.eds
A 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.
A bound description may be edited in place — Objects is the EdsDcfNet model, mutable — and
the edit is seen from the next call that reads it. But nothing synchronizes those readers with
the edit: the gate reads the description on every SDO call to that peer, the loader while a node
is being opened from the same instance, ObserveForeignPdoAsync and ObserveForeignSrdoAsync
for their file fallback, and a safety transaction for its one reading at the start. So edit a bound description
only while no call that reads it is running — in practice, while this node is idle towards that
peer. An edit during such a call is not supported, and what the call then sees is not specified:
the EdsDcfNet dictionaries are not safe for a read concurrent with a write. A reader that
enumerates — the loader, a safety transaction's reading — may fail with the dictionary's
InvalidOperationException, reported as that call's own failure (a step-D verification not
verified, with what that means for the boot — the slave is not started; a mandatory slave, or any
under a simultaneous start, halts the boot, an optional one is skipped —
Verifying, and the boot-up); a reader that looks objects up one at
a time around its own awaits —
the observers' file fallback, the gate's Contains — may answer from a description that is part
old and part new, or report no match, with no error at all. None of this is detected (#296). An
immutable description — edit, then bind again — is the planned shape for the next major (#299).
One exception follows from CiA DSP 304 §8.4.2.2: 13FFh:00 is the number of SRDOs, so a file
whose highest SRDO record is N implies the records of SRDOs 1..N, and a device that loads it
provides one the file leaves out at its defaults, deleted. The gate lets through what the device
provides there — sub-indices 00h–06h of 1301h–(1300h + N) and 00h–10h of
1381h–(1380h + N) — so that a peer's safety configuration can be written and verified
(Configuring a peer). Nothing else the file leaves out passes, and a file
without an SRDO record implies none. The implication is the file's, not the device's: a device
opened with SrdoCount above the file's highest SRDO record has records the file does not imply,
and a transfer to them is refused — the bound file has to describe the device.
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.
Observing a peer PDO¶
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 when a peer description bound for that node
(BindPeerDeviceDescription) declares the record. Without a binding the peer-SDO gate refuses
every PDO record before a frame is sent, and the whole observation — COB-ID and mapping — is
decoded from the file passed to the call. 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, or does not return a word.
The mapping is read live from 1600h–17FFh / 1A00h–1BFFh 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 64 bits uses the mapping in the
file passed to the call — and so does a value of another width than its type: sub-index 00h must
be exactly one byte (UNSIGNED8) and each entry exactly four (UNSIGNED32); a longer one is not cut
to its low bytes. The SRDO observer reads its live mapping the same way. Observations of one peer run one after another. A base-frame 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 0001h–0007h consumes its
bits and writes no signal.
A local SDO timeout or transport failure switches the remaining reads of that observation to file fallback. An offline peer therefore costs one failed live request, even when the file declares 512 PDOs. The next observation retries live reads. A peer-sent SDO abort or a gate refusal applies only to the requested entry; cancellation still propagates.
The live reads of one observation — COB-IDs, mapping — are one snapshot of the peer: the observer takes the peer's SDO channel, without waiting, and holds it to the end, so a safety transaction that starts meanwhile waits for it (as it waits for any call in flight) and another SDO call of this node to that peer is refused as in flight (SDO client and a peer's device description). A channel busy at the start — a call in flight, a transaction — makes every live read of that observation "already in flight", so the whole snapshot is the file's; a live COB-ID is never combined with the file's mapping because a caller came in between (#295).
Discovery¶
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.
PDO engine¶
using CanKit.Pro.CANopen.Pdo;
node.ObjectDictionary.WriteUnsigned(0x1019, 0, 12); // set before a non-zero 1006h
node.ConfigureTpdo(1, mapping, TpdoTransmission.Synchronous);
var od = node.ObjectDictionary;
uint cob = od.ReadUnsigned(0x1800, 1);
od.WriteUnsigned(0x1800, 1, cob | CanOpenCobId.InvalidBit);
od.WriteUnsigned(0x1800, 2, CanOpenTransmissionType.SynchronousEveryNthSync(3));
od.WriteUnsigned(0x1800, 6, 5); // count first SYNC at 5, transmit at 7, 10, 1, ...
od.WriteUnsigned(0x1800, 1, cob);
node.Pdo().RpdoTimeout += (_, e) => Console.WriteLine($"RPDO{e.PdoIndex} timed out");
The default is four PDOs per direction. CanOpenNodeOptions.PdoCount selects 1..512
(or clone existing options with options.With(pdoCount: 8));
an EDS/DCF raises the allocation to its highest declared PDO. Only PDOs 1..4 have a
pre-defined COB-ID: higher PDOs need an explicit identifier. Bit 29 of cobId selects
an extended frame; base and extended frames with the same numeric ID route independently.
SYNC, EMCY and the safety services keep their existing frame-format limits.
Mappings contain up to 64 bits in sub-index order, LSB first across byte boundaries.
BOOLEAN may use one bit (the existing eight-bit form is also accepted); fixed-width
integers must use their full declared width. Dummy 0001h:00 consumes one bit.
Communication targets are allowed where CiA 301 marks them optionally mappable
(1001h/1002h/1013h:00, 1026h:01–03, 1200h–12FFh:01–02), subject to
their own mappability and access flags. The final byte is zero-padded. Native 24/40/48/56-bit integers are available through
ObjectDictionary.AddRaw and EDS/DCF loading; ReadRaw/WriteRaw retain their exact widths.
The existing ReadUnsigned/WriteUnsigned convenience methods remain U8/U16/U32.
SYNC counter and start value. The start gate applies to every synchronous TPDO: acyclic (0),
cyclic (1..240) and synchronous RTR sampling (252). CiA 301 §7.5.2.37 gives the start value to the
PDO ("The SYNC message of which the counter value equals the SYNC start value shall be regarded as
the first received SYNC message") and exempts no transmission type. 1019h:00 = 0 uses DLC-0 SYNC; 2..240 enables a
one-byte counter that starts at 1 and wraps to 1. Boot-up, leaving NMT Stopped, enabling
the producer and resuming a zero communication period reset it to 1. Only confirmed sends
advance it; cancellation, rejection and stale queued ticks retain the value. A late
confirmation cannot override a configuration reset. Values 1 and 241..255 are rejected. A change while 1006h is non-zero
is rejected with 0800 0022h, even with the periodic producer disabled. The consumer
uses 1019h to select DLC but ignores its numeric overflow value; an unexpected length
is ignored and reports EMCY 8240h. SyncReceivedEventArgs.Counter carries the counter.
TPDO 1800h:06 = 1..240 waits for that counter after entering Operational; the matching
SYNC is the first counted SYNC, so type 3 with start 5 first transmits on counter 7. An
acyclic TPDO triggered before that SYNC goes out on it, and a type-252 TPDO samples first on it:
an RTR before then has nothing sampled to answer with and is not answered.
Start 0, or a disabled counter, preserves ordinary counting. The start value cannot
change while the PDO is valid. RPDO sub-index 6 remains absent as the specification requires.
Synchronous window. 1007h:00 is the window length in microseconds; 0 disables it.
With a window enabled, synchronous RPDOs outside it are discarded, including before the
first SYNC. That discard is a policy this implementation chose (FR-CO-053: "chosen optional
discard policy"), not a reception rule quoted from CiA 301 §7.5.2.7. Data accepted inside
the window is applied at the next SYNC. Synchronous TPDOs are checked both when scheduled and at the send hand-off: a queued sample that
outlived its window or was superseded by another SYNC is discarded. This is software
scheduling; it cannot withdraw a frame already handed to the CAN driver.
RPDO deadline. 1400h:05 is a timeout in milliseconds; 0 disables it. The next valid
reception arms it, each later reception refreshes it, and expiry raises
node.Pdo().RpdoTimeout once and emits EMCY 8250h when its EMCY producer is enabled. A short frame does not refresh it. Reconfiguration and
leaving Operational cancel it; re-entry waits for a reception. 1400h:03 is retained as
an implementation-specific RPDO inhibit parameter, without suppressing reception.
MPDO. node.Pdo() exposes ConfigureMultiplexedTpdo/ConfigureMultiplexedRpdo
with MpdoAddressMode.Source (FEh in the mapping count) or Destination (FFh).
These are event-driven only: no SYNC, RTR or TPDO event timer, and at most one valid
SAM producer per node. SendSourceMpdoAsync reads a local object; SendDestinationMpdoAsync
takes the remote node-id (0 = broadcast), multiplexer and 1..4 data bytes. Values are
padded to the four-byte MPDO data field. Both follow NMT Operational and the inhibit time.
DAM consumers write the addressed local object, or report EMCY 8230h when it is unavailable.
SAM consumers use the object dispatching arrays 1FD0h–1FFFh, including consecutive
sub-index blocks. SAM producers use the scanner arrays 1FA0h–1FCFh: an application
write emits that scanned object; TriggerTpdoAsync scans the whole configured list.
Declare these optional arrays before enabling the MPDO, either in the EDS/DCF or with
AddRaw/AddU32 and a UNSIGNED8 sub0 announcing the highest supported slot (1..254).
Scanner slots are UNSIGNED32, dispatcher slots UNSIGNED64, in the §7.5.2.39/40 layout;
writing existing scanner slots refreshes the producer. Mapping/communication records
are writable over SDO under the same access rules as ordinary PDOs.
ObserveForeignPdoAsync also accepts extended PDO keys (bit 29 set) and bit mappings;
ForeignPdoSignal.BitLength reports the exact width. For MPDO it returns the transmitted
multiplexer and four data bytes, rather than applying the observed peer's dispatcher.
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; TriggerTpdoAsync sends nothing |
| (write the byte) | 02h–F0h |
Transmitted at every n-th SYNC; TriggerTpdoAsync sends nothing. 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; TriggerTpdoAsync sends nothing |
RtrOnlyEventDriven |
FDh |
Sampled and transmitted when an RTR arrives; TriggerTpdoAsync sends nothing |
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. On an RPDO (1400h:01) bit 30 has no meaning — a consumer answers
no RTR — so it is accepted, kept in the record and ignored: the RPDO
matches its CAN-ID whether the bit is set or not.
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, except entries CiA 301 marks optionally mappable), be readable for a TPDO or writable for an RPDO, and have the mapped width; the total
must fit 64 bits; entries occupy the payload in sub-index order. A dummy entry — index
0001h–0007h, sub-index 0, the bit length of that static data type — occupies its bits
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. Each entry is checked on its own when it is written; the
length of the whole mapping is checked when it is enabled (0604 0042h on 1A00h:00 = N), over
the entries N names. While the mapping is disabled the slots keep what they held, and the entries
may be written in any order, so a slot not yet overwritten does not count against a new one.
Error control¶
Error history (1003h). Optional (CiA 301 §7.5.2.4), so off by default:
CanOpenNodeOptions.ErrorHistoryCapacity gives its number of entries (0..254), and a device
description that declares the object raises that to its highest entry. Without either, 1003h is
the application's to declare.
Every EMCY this node transmits is entered in the step that transmits it (Figure 37, transitions 1
and 3): the application's and the node's own. An EMCY that does not reach the bus is not — switched
off by bit 31 of 1014h, cancelled, or replaced while held in Stopped — and neither is 0000h,
which reports that an error ended, or a received EMCY. The newest is at sub-index 01h, older ones
move up and a full history drops the oldest. An entry is the error code in the low 16 bits and, as
the manufacturer-specific additional information, the first two manufacturer-specific bytes of the
EMCY, little-endian, in the high 16; the node's own EMCYs carry 0 there.
Sub-index 00h is the count: writing 0 empties the history, any other value is refused with
0609 0030h. The entries are ro. An upload of an entry above the count gets 0800 0024h: the
text asks that of sub-index 01h with no error present, and this applies it to every vacant one. A
file may declare the count ro, which is honoured on the bus; a count the file presets starts at 0
all the same and entries it declares rw stay ro, each with a Corrected finding.
The history is status, like 1001h: 1010h does not store it and 1011h does not restore it. An
NMT reset leaves it standing, which departs from §7.3.2.2.1 (the communication profile area returns
to its power-on values, here a count of 0), so that a master that resets a node to recover it can
still read why it had to, as the maintainer decided on #434. Power-off ends it.
Error register (1001h) and error reset. Every EMCY the node raises itself is a
communication error (CiA 301 Table 26, 81xxh/82xxh) and follows one rule (§7.2.7.1 and Figure 37;
§7.5.2.2, Table 54): 8210h for an RPDO, MPDO or SRDO shorter than its mapping, 8230h for an
MPDO destination that is not available, 8240h for a SYNC of the wrong length and 8250h for an
RPDO deadline. Each is sent once per error event. While any of them is active the register
carries bit 4 (communication error) and bit 0, because "The generic error shall be signaled at
any error situation". An error ends with its cause: a frame that fits, a SYNC of the expected
length, a destination write that lands, a reception after the deadline, or a reconfiguration of
the PDO, of 1019h/1007h or of the SRDO; leaving and entering Operational does not end one. When the last one ends, the node sends 0000h with
what the register still holds: 0 when nothing else is reported (transition 4, "All errors are
repaired"), the application's bits otherwise (transition 2). The register passed to
SendEmcyAsync is the application's part, taken when that EMCY is transmitted: a call cancelled
before then, or a held EMCY a reset discards, changes nothing. A value the application writes to
1001h through ObjectDictionary since, or one a device description gives it, is the
application's part as well. The node ORs its own bits into every EMCY, the
application's included, and never clears the application's bits, so an application 0000h sent
while a communication error is active still carries bit 4. An EMCY held in Stopped carries the
register of the moment it is sent. Reset Node and Reset Communication end the communication
errors without an EMCY (transitions 5 and 0) and leave the application's bits in 1001h. With
the EMCY switched off (bit 31 of 1014h) no error is raised, and an error that ends meanwhile
still clears the bits. A run of short RPDO frames or SRDO pairs is not consumed while disabled:
if it persists after enabling, the next short frame/pair reports 8210h once. Toggling the
producer after that report does not start a new error event (#434).
CanOpenNodeOptions.EmitGuardingErrorEmcy = true opts into 8130h for heartbeat-consumer
and local life-guarding timeouts (#423); the default is false, so existing timeout events
keep their behavior without new EMCYs. A silent heartbeat producer is one error event until
reception or removal/change of its watch; repeated timeouts do not duplicate it. Life guarding
recovers with the next poll, disabling guard time/life-time factor, or switching to heartbeat
producer mode. Multiple causes share the same register: only the last repaired cause sends
0000h, retaining application bits. An active timeout while 1014h disables the producer is
reported once if still active when it is enabled; a recovery while disabled is not replayed.
NMT reset clears this monitoring error state without an error-reset EMCY. Existing options
copy methods preserve the opt-in. Node-guarding-consumer timeouts remain event-only.
CanOpenNodeOptions.EmitControllerErrorEmcy = true separately opts into 8120h for an
observed error-passive CAN controller. The default is false and adds no controller monitor.
When enabled, the existing BusStateMonitor observes the bus on the node actor with 50 ms
polling and error/fault hints. An initially passive controller is reported on entry to
Pre-Operational. One observed passive lapse is one communication-error cause, shared with
PDO, SRDO and guarding causes. Error-active, error-warning and no-error readings repair it;
bus-off or unknown readings do not establish recovery and leave an already observed cause
active. An initially bus-off controller does not imply an observed passive error. Producer
disable/re-enable reports only still-active causes once. NMT reset clears the reported error
history and takes a fresh physical sample and monitor baseline on re-entry to Pre-Operational,
so a still passive controller is reported again without replaying a silently repaired lapse.
Bus-off/unknown at this new baseline do not inherit a pre-reset passive cause. A failed status
read in the CanKit exception family (or ObjectDisposedException) is reported through
BackgroundExceptionOccurred and retried after 50 ms on the actor clock; unexpected exceptions
are not retried. The physical read runs as a separate actor job so even an unexpected error
cannot abort reset echo bookkeeping or an LSS configuration reply. Every reset invalidates
older queued baseline jobs and retries, including when LSS restores the unconfigured node ID;
only the latest active boot may consume a new error event. Teardown also cancels this retry. Disposal (including failed
construction) removes the monitor's handlers and timer. Existing option-copy methods retain
this setting. Overrun (8110h) clear semantics and recovered-from-bus-off (8140h) sequencing
remain separate scope decisions in #423; this option does not emit either code.
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 a heartbeat or boot-up on 0x700 + id
whose state byte names an NMT state; a frame with a reserved state byte or with bit 7 set is
not reported (#255, #266), and still counts as a sign of life for the consumer.
For every node, periodic producer ticks are coalesced while a heartbeat send is outstanding; transmission recovery sends no catch-up burst, including when LSS is disabled.
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 here: it is delivered on the thread pool, never on the actor or this pump, one
report at a time in the order they were made, each handler on its own, so a handler may wait on
the node -- an SDO transfer, StoreParameters, Dispose -- without deadlocking it (#356, #381).
Emergency. CiA 301 §7.2.7.3.1, Figure 38 lays an EMCY out over eight bytes. A shorter
frame is dropped rather than padded, as an SDO frame would be — padding would invent the error
register or the manufacturer field — and raises no EmcyReceived. The first one from each
producer is reported on BackgroundExceptionOccurred (a CanOpenTransportException naming the
COB-ID and the length); later ones from that producer are dropped without a report, for the
life of the node. EmcyMessage.ProducerNodeId is the
COB-ID minus 080h, the pre-defined connection set: for a peer whose 1014h was moved off that
set it is not the peer's node-id, and the per-producer backlog above is keyed on it too.
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 reply that repeats
the previous toggle is dropped without an event and does not re-arm the life time, as CiA 301
§7.2.8.3.2.1 prescribes: "If a response is received with the same value of the toggle-bit as in
the preceding response then the new response is handled as if it was not received." The
indication of a producer that stops alternating is therefore NodeGuardingTimeout, one life
time after the last reply received. 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 it answers, 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.
An RTR the node does not answer (see below) does not start or feed life guarding either.
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 = false has the same effect with the heartbeat
producer off: no reply, and no life guarding, whatever 100Ch and 100Dh hold. 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).
NMT state and resets¶
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. This row is the server's: the node's own SDO client is not gated by the NMT state, and SdoUploadAsync and SdoDownloadAsync run in Stopped too, as SendSyncAsync does (#254) |
| 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.
SDO block transfer¶
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).
Cancelling a block download. The caller's task ends as cancelled without waiting for the
abort (0800 0000h) to be on the wire, and the sub-block being sent stops before its next segment. The abort and
the sub-block leave on separate sending tasks, though, and the sub-block checks only between two
segments, so the one segment it had already started to hand to the bus when the cancellation
took effect can reach the bus after the abort — and, if the caller starts the next transfer with
that server at once, after that transfer's initiate as well. The server then reads it as whatever
its first byte means as a command specifier. This narrow window is accepted (#377): closing it
would mean completing the cancellation only once the abort is on the wire, which changes when a
cancelled call returns. No other transfer has the window: an expedited or segmented download
sends one frame per server response, and an upload's segments come from the server.
CANopen Safety (CiA 304)¶
The binding is CiA DSP 304 version 1.0 (1 January 2001), the safety-relevant communication
on top of CiA 301: SRDOs, the global failsafe command (GFC) and the objects 1300h–13FFh.
Version 1.1 moved into EN 50325-5:2010; where this implementation follows that text instead of
V1.0 it says so. The objects are compared with it row by row in
docs/reviews/2026-11-04-en50325-5-conformance.md
(#289). Where that text is silent — the first cyclic transmission delayed 0.5 ms × node-id — V1.0
stands, as the maintainer decided on #289. Sections cited here
are those of DSP 304 V1.0. Everything lives in the
namespace CanKit.Pro.CANopen.Safety and is reached through node.Safety(), an extension on
ICanOpenNode (the interface is not widened); on a node this library did not create it throws
NotSupportedException.
The tool side — configuring a device as its configuration entity — follows CiA 319 version 2.0.0 (draft specification proposal, 2 December 2025), the implementation and configuration specification for safety-related devices — a draft, not a released specification, so a later version may still change what is built on it here: the identity verification of its §5.2, the sequence of §5.3 with the storage of step 5, and the rule of §6.2 for the checksum of an SRVT the device does not implement. What it says the device does — step 4 of §5.3, the download indication, the periodic recalculation and the start-up check of §5.4 — is built as described under Configuring and committing. Its clauses 7 and 8, the safety application layer of CiA profiles and of manufacturer-specific profiles, are not — see Not built. "CiA 319" in this section means that version.
Opening a safety node¶
CanOpenNodeOptions.SrdoCount (0..64, default 0) is the number of SRDOs the node implements.
With 0 the node is the plain CiA 301 node it was before: none of 1300h–13FFh is created and
001h is not handled, so an application may declare those objects itself (§9.4: "The
implementation of CANopen Safety shall be allowed only in safety devices"). Records above the
count are not reserved either.
A device description raises the count to the highest SRDO record it declares
(1301h–1340h, 1381h–13C0h); the option is a floor. The safety objects of the file are
loaded like the PDO records: through the validated write path, the mapping before the SRDO is
created, the file's access type honoured, every value the node refuses reported as a finding with
its abort code — a refused mapping entry also with the rule it broke, since 0604 0041h stands for
several (not mappable, not accessible, another width, no fixed width); an inverted slot that does
not repeat its plain slot and a mapping beyond 8 bytes keep the generic text with their own abort
code. A record below the highest one that the file omits exists all the same —
13FFh:00 is the number of SRDOs (§8.4.2.2), so records 1..n exist and a master reaches every
one — at its defaults, deleted, with the node's default access, and is reported as SuppliedDefault. A mapping
record without its communication record is applied with that SRDO deleted, and a mapping the
node refuses leaves that SRDO deleted. 13FEh is applied last, so a file whose checksums match its records loads as a
valid configuration; a file whose checksum does not match loads without a finding, and the node
sets 13FEh back to 00h at that write (CiA 319 §5.3 step 4, below). EdsDcfNet evaluates $NODEID in the form $NODEID+<constant>: the pre-defined
COB-ID FFh + 2 × node-id is not of that form, so a file states it as a number or as
$NODEID+<constant> for its own node-id.
using CanKit.Pro.CANopen.Safety;
using var node = CanOpen.OpenNode(bus, nodeId: 0x05, new CanOpenNodeOptions { SrdoCount = 2 });
node.ObjectDictionary.AddU8(0x2001, 0x00, 0); // the safety input it transmits
var safety = node.Safety();
safety.ConfigureSrdoProducer(1, new SrdoMapping().Add(0x2001, 0x00, 8),
TimeSpan.FromMilliseconds(30));
safety.CommitSafetyConfiguration(); // checksums, then 13FEh = A5h
safety.SrdoStateChanged += (s, e) =>
Console.WriteLine($"SRDO{e.SrdoNumber} valid={e.IsValid} {e.Reason}");
EnableChangeOfStateSrdo (default on) makes an application write to an object mapped in a
transmit SRDO transmit that SRDO at once, as EnableChangeOfStateTpdo does for TPDOs; a write
from the bus never triggers one.
The objects¶
DSP 304 §8.4.2.2 Table 6, created at these defaults, which the comparison holds against EN 50325-5:2010:
| Object | Sub-index | Default | Each write is held to |
|---|---|---|---|
1300h GFC parameter |
00h |
0 | 0 or 1 (0609 0030h). Writable in Operational: it is not covered by the checksum. The node sets it to 1 when it requests or receives a GFC (EN 50325-5:2010 §6.4.2.1); it is not a switch in front of either. |
1301h–1340h communication record of SRDO n |
00h |
6 | Constant (0601 0002h). |
01h direction |
SRDO 1 of node-id 1..32: 1, of node-id 33..64: 2 (EN 50325-5:2010 Table 5); else 0 | 0 deleted, 1 transmit, 2 receive; 3..255 0609 0030h. Creating needs both COB-IDs set, in range, consecutive and not held by another existing SRDO, and every mapped object accessible in that direction (a producer reads, a consumer writes). |
|
02h refresh time (tx) / SCT (rx), ms |
50 where 01h defaults to receive, else 25 (EN 50325-5:2010 Table 5) |
1..65535 (0609 0030h for 0). |
|
03h SRVT, ms |
20 | 1..255. Part of the checksum for both directions; only a consumer acts on it. | |
04h transmission type |
254 | Constant: a download 0601 0002h, a local write 0609 0030h. |
|
05h COB-ID 1 |
FFh + 2 × node-id for SRDO 1 of a node-id 1..64, else 0 | 0 or an odd CAN-ID 101h..17Fh, bits 11..31 clear; not changeable while the SRDO exists; not one of another existing SRDO (0609 0030h). |
|
06h COB-ID 2 |
100h + 2 × node-id, likewise | 0 or COB-ID 1 + 1 (102h..180h), same rules. | |
1381h–13C0h mapping of SRDO n |
00h |
0 | 0 or an even count 2..16 (0609 0030h); every slot up to it filled (0602 0000h), each odd slot passing the slot rules below again (its object may have been re-declared since the slot was written), each even slot equal to the odd one before it — plain, then inverted (0604 0041h), at most 8 bytes in total (0604 0042h). |
01h–10h |
0 | Byte-aligned 8..64 bits, an existing mappable object outside 1000h–1FFFh of a fixed width equal to that width — no string or domain, this implementation's rule — no dummy (0607 0010h, 0602 0000h, 0604 0041h). Mapping writes while the SRDO exists, and slot writes while the count is not 0, 0601 0000h. |
|
13FEh configuration valid |
00h |
0 | Any value; only A5h means valid, and any other is held as 0 (EN 50325-5:2010 §6.4.1.6). |
13FFh safety configuration checksum |
00h |
SrdoCount |
Constant (0601 0002h). |
01h–n |
0 | UNSIGNED16, the checksum of SRDO n. Bus access as for the records (below); a description that declares it ro or const is honoured — a download is refused with 0601 0002h and leaves 13FEh as it was (CiA 319 §5.3). |
The records, 13FEh and 13FFh refuse every write in Operational with 0800 0022h (§8.3.2.4,
note 1); reading stays allowed. The transition into Operational takes the dictionary's write gate,
under which a direct write checks the state and stores its value, so a write racing it is either
stored before it — and seen by its configuration check — or refused. Every accepted write to a
record or to 13FFh sets 13FEh back to 0 (§8.4.2.2, "automatically 0"), so a changed parameter can never sit beside a stale A5h. A
PDO cannot take a CAN-ID in 101h..180h (CiA 301 Table 40), so SRDO COB-IDs are only checked
against each other.
The objects are communication-profile objects like the PDO records, but read/write on the bus
outside Operational without WritableCommunicationParameters, which governs the PDO records alone:
EN 50325-5:2010 Tables 5, 8, 11, 13 and 15 give them rw, and its §8.1 configures them by SDO
download. A file may declare them ro. Reset Node and Reset Communication return them to their power-on values, 1010h
"save" stores them and 1011h "load" brings them back. Putting a stored configuration back does
not clear 13FEh, so a configuration that was valid when it was saved is valid again after the
reset.
Configuring and committing¶
ConfigureSrdoProducer(n, mapping, refreshTime) and ConfigureSrdoConsumer(n, mapping, sct,
srvt) write record n in the order §8.4.2.2 requires for a mapping change: the SRDO deleted
(sub-index 01h = 0), the mapping disabled, its slots (each object twice, plain and inverted),
its count, the times, the COB-IDs, and the direction last. Without COB-IDs SRDO 1 of a node-id
1..64 takes its pre-defined pair (§8.3.3 Table 4); any other SRDO needs them
(ArgumentException). A producer's sub-index 03h keeps its value (default 20 ms).
DeleteSrdo(n) sets the direction to 0. All three throw InvalidOperationException in
Operational and on a node without SRDOs, and ArgumentException naming the abort code for a value
the dictionary refuses. A configuration is checked whole before its first write — the same writes
through the same validator against a copy of the dictionary — so one the dictionary would refuse
part-way (a mapped object the direction cannot use, COB-IDs another SRDO holds) changes nothing:
the SRDO, its checksum and 13FEh stay as they were.
An object an existing SRDO maps (sub-index 01h ≠ 0), producer or consumer, cannot be
re-declared: Add* throws InvalidOperationException naming the SRDO. The checksum covers the
mapping entry, not the declaration behind it, so a new width or access would otherwise reach the
bus or the application with the configuration still valid. Writing the object's value is not
affected. To re-declare it, delete the SRDO first (§8.4.2.2, object 1381h–13C0h: "For
changing the SRDO mapping first the SRDO shall be deleted"); creating it again checks every
mapped object anew. The PDOs have no such rule — a re-declared RPDO object only surfaces when a
PDO is written into it.
CommitSafetyConfiguration() is §9.2 for a node configured locally: it writes 13FFh:n for
every SRDO, computed by SrdoCrc.Compute, and then 13FEh = A5h. A5h has to come last,
because every checksum write clears it. The checksum (§8.4.2.2) runs over the direction (1 byte),
the refresh time or SCT (2), the SRVT (1), COB-ID 1 (4), COB-ID 2 (4), the mapping count (1) and,
per mapping sub-index, that sub-index (1) and its value (4). V1.0 gives the polynomial
x¹⁶ + x¹² + x⁵ + 1 and nothing else; EN 50325-5:2010 §6.4.1.5 adds the seed 0000h, and its formula
(2) lists a multi-byte field low byte first. The initial value used here is that of CRC-16/XMODEM
(SrdoCrc.Crc16Xmodem, also used by the SDO block transfer), multi-byte fields little-endian —
the CANopen byte image, as CANopenNode's CO_SRDOcalcCrc feeds them (#289). Releases before this
one fed them MSB-first: a 13FFh written then no longer matches. SrdoCrc.Compute takes the times as whole milliseconds, rounded half to even
(Math.Round), and refuses with ArgumentOutOfRangeException what does not fit the field after
rounding — a negative time, a refresh time or SCT above 65535 ms, an SRVT above 255 ms — and a
direction above 2, rather than checksum a value cut to the field. 0 is accepted: the device's own
records are checksummed as they are, and the dictionary refuses 0 itself.
At every transition to Operational each SRDO is checked (§9.5, last rule; §8.3.1 step D):
13FEh = A5h and 13FFh:n equal to the checksum of the record. Without both the SRDO is
ConfigurationInvalid: a producer does not transmit, a consumer reports invalid at once. The
node's NMT state is not affected.
The device side of CiA 319 adds three more moments at which the node compares the checksums of
its existing SRDOs (sub-index 01h ≠ 0 — a deleted SRDO has no configuration to protect, and
§9.5 does not check it either) with 13FFh and, on a mismatch, sets 13FEh to 00h itself.
The checksum is recomputed over the record as the dictionary holds it, every mapping slot raw,
the inverted ones included (SrdoRecords.RecordChecksum; the transition to Operational uses it
too), so a record changed behind the dictionary's back in any slot is a mismatch:
- At the write of
A5h(§5.3 step 4), from any writer — the configuration entity's SDO, a local write, a device description applying13FEhlast. The readback of step 4 therefore sees00hon a device whose checksums do not fit, instead of theA5hjust written. - After a reset (§5.4), over what the reset put back, before the boot-up goes out. The
stored values come back as stored; only
13FEhis cleared. - Periodically, when
CanOpenNodeOptions.SrdoConfigurationCheckIntervalis set (§5.3 says the SRD "should" do this and names no interval, so it is opt-in;Withkeeps the interval,WithSrdoConfigurationCheckIntervalreplaces it). Besides the checksums, the check compares the checksummed objects of the SRDOs that existed then — each one's record, mapping and13FFh:n— byte for byte with the image taken whenA5hwas written or restored: every write to one of them clears13FEh, so whileA5hstands any difference was made behind the dictionary's back, a direction that now reads as 0 or reserved included. A deleted SRDO is not in the image; it has no configuration to protect. A mismatch found in Operational makes every SRDO invalid as the transition would —ConfigurationInvalid, or no longer existing (reasonnull) when its record reads as deleted — and ends the send period as leaving Operational does: a pair waiting, a pair already queued on the chain and the inverted half of a held pair go nowhere; the consumer reports invalid, andSrdoStateChangedsays so. CiA 319 specifies no notification of the configuration entity.
And the clearing rule of §5.3 after step 5 holds as the dictionary works: every accepted write
to a safety parameter clears 13FEh, whether or not the value changed; a write the node refuses
— read-only, out of range, in Operational — leaves it.
Producer¶
A valid transmit SRDO sends its pair every refresh time while the node is Operational. The first
transmission is delayed by 0.5 ms × node-id (§9.5), so the producers of a network do not start in
step. Each transmission is the plain frame on COB-ID 1 followed by its bitwise inverse on COB-ID
2 (§8.1), handed on as one unit; the pairs of all SRDOs of the node go out on one ordered send
chain, so an inverted frame can never overtake its plain frame. TriggerSrdoAsync(n) and a change
of state transmit at once and restart the refresh cycle from there; the refresh time is the
longest interval between two transmissions the engine schedules. On the bus a pending pair waits
behind a stalled one (below).
At most one pair per SRDO is in flight. A pair that comes due while the previous one is not yet
confirmed becomes that SRDO's pending pair, replacing an older pending one — the latest data wins —
and is sent as soon as the previous pair completes. Leaving Operational, or disposing the node,
drops the pending pair, and a pair already on the send chain starts no further send — neither another
SRDO's pair queued behind the one in flight nor the inverted half of the one in flight (§8.3.2.2):
a consumer that times out on its SRVT is safer than one that refreshes its SCT on a stale pair.
Each frame is handed to the bus service with a cancellation token of its Operational period,
which leaving Operational or disposing cancels; the token is honoured up to the bus service's
driver call, including a send task already waiting there behind another send (#294). A frame the
driver has been handed goes out — as the driver queue would on any controller. A send that fails or throws is reported on BackgroundExceptionOccurred; the cycle goes on and the
SRDO stays valid. A mapped object that is gone, has no fixed width, or no longer has the width of
its mapping entry stops the producer instead (§8.1.3.1): no pair padded or cut to fit goes out,
the failure is reported on BackgroundExceptionOccurred, and the SRDO is
MappedObjectUnavailable — no further cycle and no TriggerSrdoAsync transmission — until it is
rebuilt (a record written, or Operational entered again). With the rule on re-declaring above this
takes a path around Add*; it is the guard behind that rule.
GetSrdoState(n) of a producer is valid while the node is Operational with a
valid configuration. The state is a snapshot of the actor's view: right after OpenNode the
records are taken on the actor's first turn, so a read before any call that round-trips the actor
(State, say) has returned may still show direction None. Its LastValidAt is wall-clock UTC
(DateTime.UtcNow), not the node's time source: cycles, SCT and SRVT run on the time source, so
under a ManualTimeSource they move with the virtual clock while LastValidAt moves with the
real one.
Consumer¶
A receive SRDO starts each Operational period invalid with NotReceived and its SCT running.
Then (§8.1.1, §8.1.3.1, §9.5):
- the plain frame starts the SRVT; a second plain frame replaces the first and restarts it;
- the inverted frame must follow within the SRVT (
ValidationTimeExpiredotherwise), must have a plain frame before it (OutOfOrder), and must be its bitwise inverse with the same length (Mismatch) — except that an inverted frame arriving while the SRDO is stillNotReceivedas armed, before any pair (a short one, not processed, included), expiry or mismatch of this Operational period, is no pair of this consumer's and no violation: a producer already running when the consumer entered Operational between its plain and its inverted frame. It is consumed without a transition, the SCT runs on, and the next pair makes the SRDO valid; - both frames are classic CAN frames (§8.1): a CAN FD frame, or a payload over 8 bytes, on either
id is no SRDO frame and is rejected as a wrong inverse is —
Mismatch, a pending plain frame dropped, nothing written, noSrdoReceived, the SCT not restarted, no EMCY; - a complete pair writes the mapped objects, raises
SrdoReceivedand restarts the SCT; when the SCT elapses without one the SRDO isSafeguardCycleExpired; - a mapped object that refuses its bytes — a write validator, or a declaration that no longer
takes them — ends the writing there: the objects before it keep this pair's bytes, the refused
one and those after it keep their previous values, and the refusal is reported on
BackgroundExceptionOccurred. The SRDO isActuationRefused, and the pair counts as not received, as §8.1.3.1 leaves a pair too short for the mapping unprocessed: noSrdoReceived, and the SCT is not restarted. The reason is the most recent cause: an SCT still running keeps running from the last pair taken and its expiry reportsSafeguardCycleExpired; one that had already expired stays expired. On a node this is a safeguard, likeMappedObjectUnavailable: no public path produces a refused mapped write today — the node installs no write validator on application objects and offers no public one, and it refuses to re-declare a mapped object; - a pair shorter than the mapping is not processed and produces EMCY
8210h(when EMCY is enabled) once, until a pair of the right length arrives or the SRDO is rebuilt — as for an RPDO; a longer one, within the 8 bytes of a classic frame, uses its first bytes (§8.1.3.1).
The next valid pair makes an invalid SRDO valid again — there is nothing to reset — except
ConfigurationInvalid, which needs a new commit and a transition to Operational.
SrdoStateChanged reports the transitions, not every pair, and is a critical event like a
heartbeat timeout: it is never discarded to make room in the event queue, and a second identical
one still waiting behind the first is folded into it. A remote frame, a frame outside Operational
and the node's own producer frames (on a bus that echoes) are ignored. Leaving Operational makes
every SRDO NotOperational and stops SCT and SRVT.
GFC¶
The global failsafe command is COB-ID 001h with no data (§8.2). SendGlobalFailsafeCommandAsync
sends it at once — it does not wait behind SRDO pairs — and only in Operational; otherwise it
throws InvalidOperationException. The request sets 1300h to 1, and so does a received GFC;
the value is no precondition of either (EN 50325-5:2010 §6.4.2.1, §7.2.1) and stays until the
application or a tool writes it back. The returned task is the send itself, not the
node's actor turn that started it: it completes when the bus service confirmed the send — on its
echo path once the echo arrived, otherwise once the driver accepted the frame — or reported its
failure (on BackgroundExceptionOccurred, as for every frame), or when the send was cancelled.
Like an SRDO frame it is handed to the bus service with the token of the
Operational period, here linked with the caller's token, so leaving Operational, disposing the
node or cancelling the caller's token cancels a send that has not reached the bus service's
driver call — including one already waiting there behind another send (#294); a frame the driver
has been handed goes out. A send cancelled by the period ends
quietly; one the caller cancelled — before the call ran or while the send waited — ends with
OperationCanceledException, and so does a send under which the bus service is disposed, which
is also reported on BackgroundExceptionOccurred as a transport failure. A GFC is reported by
GlobalFailsafeCommandReceived while Operational (#507), and only as a classic CAN frame with
DLC 0 (EN 50325-5:2010 Figure 10) — a CAN FD frame on 001h is no GFC and is ignored. On a bus that
echoes, the node's own GFC is reported too. Like SrdoStateChanged the event is critical, and
identical ones still waiting are folded into one.
Configuring a peer¶
ConfigurePeerSafetyAsync(peer, configuration) is the tool side of §9.2, Figure 9. A
PeerSafetyConfiguration holds 1300h and, per SRDO number, the communication parameter and the
mapping. Add refuses what the peer's validator would refuse whatever its state, before a
configuration can delete the peer's SRDOs (§8.4.2.2): a direction other than tx or rx, COB-IDs
that are not an odd id of 101h..17Fh and the next one, a refresh time or SCT outside 1..65535 ms or
an SRVT outside 1..255 ms, and a COB-ID another SRDO of the same configuration uses;
PeerSafetyConfiguration.FromDeviceDescription(dcf, nodeId) builds it from a DCF's
parameter values (default value where none, $NODEID resolved) as what a device with that
node-id holds after loading the file, so that step D against a slave's own DCF expects what the
slave holds. It is not a second reading of the file: the node's own loader — the same code, over
a dictionary of its own — applies the file, and the expectation is every SRDO that exists
afterwards, with the records it holds. A value the file leaves out, does not parse or the device
refuses keeps the device's default (25 ms refresh time or SCT, 20 ms SRVT, the pre-defined
COB-IDs of SRDO 1 for a node-id 1..64, §8.3.3); an SRDO whose creation the device refuses —
COB-IDs that are not a consecutive pair or that another existing SRDO holds, no mapping record,
a mapping that does not apply (§8.4.2.2) — is left out, as the device leaves it deleted.
The call is the configuration entity of CiA 319 clause 5. Before its first frame it runs the
peer-SDO gate over every transfer it will make whatever the peer answers — 1018h:01–03,
13FFh:00, 1300h, 13FFh:n of each SRDO named, 13FEh, the 1010h sub-index asked for — so a
file that does not declare one of them is refused with nothing sent (PeerSdoAccessException),
not mid-transaction with the peer part-way through its configuration; an SRDO named above the
file's highest SRDO record, which the gate could never let through, is refused likewise
(ArgumentException). The transfers that depend on the peer's count — those for the SRDOs it has
that the configuration does not name — pass the gate once the count is read, still before the
first write (below). Its first frames verify the peer's identity (§5.2): 1018h:01
(vendor-ID), :02 (product code) and :03 (revision number) are uploaded and compared with the
bound EDS/DCF's own 1018h:01–03 — "the entries in the EDS", as declared: the ParameterValue,
the DefaultValue where none. All three are read; a peer that is not the device the file describes
ends the call with those entries as its Mismatches — the four bytes the file's entry encodes
to, the bytes the peer returned, whatever their number — and nothing written. §5.2 requires all
three of an SRD, where CiA 301 makes :02 and :03 optional: the file must declare each with a
value, and the peer must implement them; one that aborts an upload of them is not an SRD this
procedure can configure, and the abort propagates — unless an earlier entry already differed, when
the peer is another device and that answer is reported as a mismatch with no bytes read. The
file's [DeviceInfo] numbers are a second source: where set, they must agree with the 1018h
entries, and a file that contradicts itself is refused before the first frame
(InvalidOperationException), as is one whose three entries are all 0, which carries no
identity, or one declaring an entry without a value this implementation reads (a plain number; a
$NODEID formula is no identity); an absent [DeviceInfo] key, which EdsDcfNet reads as 0, is
none of these. Without a bound
description the call is refused before any frame, naming 1018h:01 — the first transfer, which
would have nothing to be compared with. The further checks the note of §5.2 names (firmware and
hardware version, serial number, physical location) are the application's.
Then the call reads the peer's SRDO count from 13FFh:00 and refuses a configuration naming an SRDO
above it (ArgumentException) before writing anything; a peer whose 13FFh:00 is not exactly one
byte — it is UNSIGNED8, so none, two or four bytes are refused rather than read by their low byte —
or a count above 64 — the most 13FFh:00 can be (§8.4.2.2) — is an InvalidOperationException, before any
further frame and for a verification alike: SRDOs claimed beyond 64 could never be configured or
verified, so the count is not taken as 64. So is a count above the bound file's highest SRDO record
(InvalidOperationException naming both): the gate implies no record above it, so the SRDOs the
peer has beyond the file could be neither deleted nor verified, and the refusal would otherwise
come at the first of them — after the delete-first pass had deleted the peer's SRDOs and cleared
its 13FEh. With the count known, the transfers the §9.2 list makes for every SRDO up to it, named
or not — sub-index 01h of each record, 13FFh:n of each — pass the gate before the first write,
as the fixed transfers did before the first frame. Times outside 1..65535 ms (cycle) and 1..255 ms (SRVT, for every
direction, because the checksum covers it) are refused before the first frame. So is a mapping
the peer's bound EDS/DCF rules out, because §9.2 deletes every SRDO of the peer first and a
refusal at the mapping would leave it without its configuration: each mapped object must be
declared there, flagged mappable (PDOMapping; an absent key reads as not mappable),
of a data type with a fixed width (no string or domain, as locally; an entry without
DataType, or with a type code this implementation does not know, is refused too, since its
width cannot be known) and as wide as it, and readable for a producer or writable for a consumer
(AccessType). What the bound file lets the tool see is refused locally; what only the peer can
judge — a rule of its own beyond its file — is the peer's (ArgumentException, nothing sent).
A pair the configuration downloads that the bound file declares ro or const — 13FFh:n, whose
access a description may restrict, 13FEh, 1300h, a record entry, the 1010h sub-index asked
for — is one a peer honouring its file refuses with 0601 0002h, so it is refused here
(InvalidOperationException) at the same two moments: before the first frame when the
configuration alone fixes the write, once the count is read for an SRDO it does not name — never
mid-transaction, after the delete-first pass. A tx SRDO's sub-index 03h declared read-only is
not downloaded (below) and is no such pair. Then it writes
sub-index 01h = 0 for every SRDO of the peer — so that two SRDOs can exchange their COB-IDs —
then per SRDO the record in delete-first order (an SRDO the configuration does not name stays
deleted), then 1300h, then every checksum. The mapping is its count and the slots its objects
use; a slot above the count is no part of the SRDO and is neither written nor read, so the peer's
file need not declare it. It reads all of it back and compares byte for byte.
Only without a difference does it write 13FEh = A5h and read that back too.
That is §5.3 of CiA 319, steps 1 to 3: the configuration data, then the checksums, then the
readback, then A5h. Step 4 is the peer's — it recalculates the checksums itself and, where one
does not match, sets 13FEh back to 0 — and the readback of A5h shows it, so a peer whose
checksum convention differs from this implementation's (#289) fails the result instead of running
on a configuration it did not accept. Step 5 is optional and asked for with
PeerSafetyConfiguration.Storage: AllParameters downloads the "save" signature to 1010h:01,
CommunicationParameters to 1010h:02, after the acknowledgement, so that what the peer stores
includes 13FEh = A5h and 13FFh; PeerSafetyResult.Stored says the peer confirmed it, and
nothing is stored when the readback differed. Storage is settable, so a configuration from
FromDeviceDescription can ask for it too. A sub-index the bound file does not declare is
refused before the first frame (PeerSdoAccessException), as the gate would refuse the transfer —
but then after the acknowledgement; a store the peer aborts comes after the acknowledgement,
which stands, and the result says so: Succeeded, not Stored, the abort in
PeerSafetyResult.StorageAbort rather than thrown away with the acknowledged state.
An SRD need not implement the SRVT of a tx SRDO, and its EDS "should include the SRVT parameter
for all supported SRDOs" (CiA 319 §6.2): a tx SRDO whose communication record the bound file
declares without sub-index 03h is one the peer is taken not to hold an SRVT for, and one whose
03h the file declares read-only (or const) is one the peer holds and will not take. The
configuration must say 20 ms for such an SRDO — the value §6.2 prescribes for the calculation,
and what such a peer computes; another value is refused before the first frame
(ArgumentException), because it cannot be put on the peer, and a checksum over it would not be
the peer's. The file is not trusted alone where it says nothing: an undeclared sub-index is
written all the same (the gate lets it through as an implied record entry), so that a peer which
does hold it writable ends with 20 ms rather than with a value from an earlier configuration — a
delete writes sub-index 01h only. A peer that aborts that write with 0609 0011h (sub-index
does not exist) does not implement it and is not asked to read it back; one that aborts it with
0601 0002h (read-only) holds it, and is read back and compared like every other value — so a
peer holding another value than 20 ms is a mismatch. A peer that took the write answers for it:
an abort on its readback propagates. A sub-index the file declares read-only is not written, only
read back. A consumer's SRVT is not optional, and neither is one the file declares writable: their
aborts propagate. A record the file does not declare at all, implied by a higher one, counts as
whole, as a device provides it. The verification applies the same rule, so a DCF without the
sub-index verifies against what the device holds.
PeerSafetyResult.Succeeded means acknowledged. Otherwise Mismatches lists each
(index, sub-index) with the bytes written and the bytes read — or, for 1018h, the bytes the
file's identity encodes to and the bytes the peer returned, with nothing written — and the result is not Succeeded:
A5h is written only after a clean readback, and a readback of A5h that differs fails the
result too. An SDO abort, a timeout or a refusal by the peer-SDO gate propagates as from
SdoDownloadAsync. 13FEh is 0 once at least one parameter write has been accepted, because
every such write clears it; an abort before that — for example 0800 0022h from an Operational
peer, which refuses the first write — leaves it unchanged. Every transfer
passes the peer-SDO gate, so the peer's EDS or DCF must be bound (BindPeerDeviceDescription).
One configuration or verification per peer runs at a time, and it reserves the peer's SDO channel for the whole transaction: it waits for a call to that peer already in flight (its cancellation token ends the wait), and while it runs another SDO call of this node to that peer is refused as already in flight, so nothing interleaves between two of its transfers (a write after the readback could otherwise stale the checksum it acknowledges). It also works from the peer description as it was when it started — the moment it owns the peer's channel, after any call it waited for: its pre-check, the identity and the gate of each of its transfers read a snapshot taken then — the declared pairs of the safety area 1300h–13FFh, of 1018h and of 1010h, the file's identity, the records declared without an SRVT, the highest SRDO record, and the described entries of the mapped objects — not the bound instance, which is publicly mutable. Rebinding, unbinding or editing the bound description in place during it takes effect for the next call — an edit landing while the snapshot is being taken is the one case the snapshot does not cover (SDO client and a peer's device description). The same holds for the configuration or expectation passed in: a copy taken at that moment is what is checked, written and compared, so an SRDO added to the caller's instance meanwhile is not part of the transaction.
Verifying, and the boot-up¶
VerifyPeerSafetyConfigurationAsync(peer, expected) is §8.3.1 step D without writing anything:
it uploads 13FEh (must be A5h), 13FFh:n of every expected SRDO (must equal the checksum
of the expected record) and the records, and compares them with what a configuration with
expected would have written (1300h aside, which step D does not list). An SRDO the
expectation does not name must be deleted (sub-index 01h = 0); its checksum is not compared,
because the device checks the checksums of existing SRDOs only (§9.5). A tx SRDO whose record the
bound file declares without a writable sub-index 03h is expected at the checksum over 20 ms, and
a peer that aborts the upload of a sub-index 03h the file does not declare with 0609 0011h does
not implement it — no difference (CiA 319 §6.2, as for a configuration); every other abort
propagates. 13FFh:00, the checksums of the SRDOs expected and 13FEh pass the gate before the
first frame; a count above the file's highest SRDO record is refused as for a configuration, and
sub-index 01h of every record up to the count passes the gate once it is read. The identity of CiA 319 §5.2 is not read: it
belongs to the configuration, and the boot-up binds a DCF for a node-id.
The active flying master runs step D itself. An assigned slave whose bound DCF declares at least one SRDO is a safety slave; an EDS carries no parameter values and never makes one. Before NMT Start the master verifies the slave against its DCF — the expectation is taken from the description bound at the moment the verification owns the slave's SDO channel, the moment its own snapshot is taken (Configuring a peer), so a rebind or an edit while it waits for a call in flight is compared, not a stale reading; the expectation and the gate of the verification's transfers come from one reading of that description. No safety DCF bound at that moment means nothing to verify — no verification is recorded, and the boot looks again: a DCF bound since is verified, none means the slave is started as any other:
- verified — the slave is started as any other;
- not verified (a difference, or an upload that failed; an exception is also reported on
BackgroundExceptionOccurred, except when the result arrives during a held cold reset, where it is acted on after the hold) —FlyingMasterChangedsignalsSlaveSafetyConfigurationInvalidfor that slave and it is not started. A mandatory slave, or any safety slave under a simultaneous start (1F80hbit 1 set and bit 2 clear), halts the boot with the error reaction of1F80hbits 4 and 6, as a boot timeout does; an optional one is skipped and the boot goes on.
This node's own self-start and the moment of a simultaneous start wait for verifications still running. A verification that ends while a forced Reset Communication is held is acted on only after the hold — a success is kept, a failure is verified again — and a result from a boot that has since been cancelled is discarded. A safety slave that is already Operational when first seen — running before this master took over, or keep-alive — is not started by this master and therefore not verified: step D is "before NMT Start".
While any assigned slave is a safety slave, the master sends no NMT Start to node 0: a broadcast
would also reach a safety slave that has not announced yet — keep-alive, running before this
master took over, or with its boot-up still in flight — before step D. A simultaneous start
(1F80h bit 1) keeps its moment — every mandatory slave seen, no verification running — but at
that moment every slave seen so far is started with an NMT Start of its own, a safety slave only
once verified, and a slave that announces later is started on its own as it announces. The cost:
one frame per slave instead of one broadcast, and a keep-alive slave that never announces (no
heartbeat, no boot-up) is not started by this master — as with bit 1 clear.
Only the boot-up is gated: an NMT Start to node 0 the application requests itself — through
SendNmtCommandAsync with node 0 or 1F82h sub-index 80h — goes out as requested.
Observing a peer SRDO¶
ObserveForeignSrdoAsync(peer, cobId1, frame1, frame2, description, sink) splits another node's
SRDO pair into the sink, as ObserveForeignPdoAsync does for a PDO, and writes nothing to this
node's dictionary. The record is the one whose live 1301h–1340h:05 equals cobId1; a word
that was read decides, matching or not, and the file's value is used only for a record whose
upload failed or returned no word — a word is exactly four bytes (UNSIGNED32), and a direction
(sub-index 01h) exactly one (UNSIGNED8); a value of another length counts as not read. A COB-ID
word with a bit above bit 10 does not match. Only an
existing record matches: one whose direction (sub-index 01h, live, or the file's when that upload
fails) is not 1 or 2 — 0, or a reserved value 3..255 — is skipped, because a deleted record keeps
its COB-IDs and another SRDO may have taken them over (the device refuses only the ids of an
existing SRDO, §8.4.2.2); a direction neither source yields is not taken for a deletion. The two
frames must have the same length and be bitwise inverse (§8.1). The mapping is read live from
1381h–13C0h — every slot up to the count, the inverted ones included, so a full mapping takes
16 slot uploads rather than 8 — or from the file when that read fails: a slot that cannot be read,
even or odd, or a value of another width than its type (sub-index 00h exactly one byte, a slot
exactly four), means the live mapping is not read. Either is used only when it is one the device's
own loader takes: each even sub-index up to the count is declared and repeats the odd one before it
(§8.4.2.2, 1381h–13C0h: the inverted object), the same rule the dictionary's mapping validator
applies; otherwise the pair is not decoded and the reason names the slot. A live even slot that was
read and differs from its odd slot is not decoded either, and the file is no fallback for it: live
wins. Unlike for a PDO
(FR-CO-030), a live mapping is read in full whenever its count is, even beyond the slots the file
declares: the peer-SDO gate lets through every slot of an SRDO record the bound file implies
(SDO client and a peer's device description), so
safety data is never split by a file's mapping while the device's own is readable. A mapping
with a dummy entry and a frame shorter than the mapping are reported not decoded. Signals carry ForeignPdoKind.Srdo and the SRDO number. SRVT and SCT are
not judged: the caller holds the timestamps.
The live reads of one observation — COB-ID, direction, mapping — are one snapshot of the peer: the observer takes the peer's SDO channel, without waiting, and holds it to the end, so a safety transaction that starts meanwhile waits for it (as it waits for any call in flight) and another SDO call of this node to that peer is refused as in flight. A channel busy at the start — a transaction, a call in flight — makes every live read of that observation "already in flight", so the whole snapshot is the file's; the live COB-ID of one configuration is never combined with the file's mapping of another.
Read-only peer safety inspection¶
node.Safety().ReadPeerSafetyConfigurationAsync(peer, expected: null, token) (FR-CO-060)
acquires a tool-side PeerSafetySnapshot without a target DCF. Bind a peer EDS/DCF first with
BindPeerDeviceDescription: it still governs every SDO upload. This optional operation uses
ICanOpenSafetyInspection; existing implementations of ICanOpenSafety need no new members.
The extension reports NotSupportedException for implementations without inspection support.
node.BindPeerDeviceDescription(peer, peerEds);
var image = await node.Safety().ReadPeerSafetyConfigurationAsync(peer, cancellationToken: token);
foreach (var srdo in image.Srdos)
Console.WriteLine($"SRDO {srdo.Number}: CRC {srdo.SignatureComparison}, usable input {srdo.EligibleForMonitoring}");
foreach (var finding in image.Findings)
Console.WriteLine($"{finding.Index:X4}:{finding.Subindex:X2} {finding.Kind}: {finding.Message}");
The operation reserves this client's peer-SDO channel, then copies the binding and optional
expectation. It reads identity 1018h:01..04, 1300h, 13FEh, 13FFh and the peer's described
communication/mapping records. The live count cannot expand the scan beyond the description
or 64 SRDOs. Each mapping's advertised slots are retained up to the bounded 128-slot record
range, through the existing access gate; interpretation retains this implementation's fixed,
byte-aligned, 8-byte mapping limit. Missing entries, aborts (with code and origin), wrong widths,
unsupported mappings and optional expected-value failures are structured per-entry findings.
It sends uploads only, no downloads, NMT, store/commit, SRDO or GFC commands, and never changes
the peer's state to make an entry readable. Cancellation can send the SDO client's normal abort;
cancellation and node disposal propagate rather than returning a completed partial result.
Entries retains owned raw bytes and provenance; RecheckEntries retains the repeated uploads.
An explicitly declared ParameterValue (otherwise DefaultValue) is retained as DeviceDescription
when a live read fails, never as live evidence. Such fallback values cannot complete a live
image or satisfy its signature/expected comparison. An undeclared producer SRVT answered by
the peer with 0609 0011h is represented explicitly as CiA319Default (20 ms), with the abort
retained, following the existing CiA 319 rule. A local timeout or another abort does not establish
that rule. A live producer SRVT outside the existing non-writable-parameter rule is retained
unchanged and diagnosed, rather than silently replaced. CRC serialization and mapped-object
validation reuse the configuration/verification implementation. The unresolved version and
byte-order interoperability questions in #289
remain visible in Findings.
DeviceConfigurationValid is only the live 13FEh flag. Each SignatureComparison compares
the live 13FFh:n with an independently recomputed signature, using live fields and the explicit
SRVT rule only. Deleted records' stale signatures are not assessed. Their inactive timing/COB-ID values do not
produce active communication errors; raw values and unavailable reads remain visible. ExpectedComparison is
separate and only requested when a configuration was supplied. A complete acquisition without
observed changes is required for Match; a known difference can still report Mismatch in a
partial image. A true flag cannot replace either
comparison. IsComplete describes the bounded acquisition and successful required rechecks;
it does not imply valid parameters. A mapping must also pass the existing declared-object,
fixed-width, access-direction and COB-ID uniqueness checks to be eligible for monitoring.
Holding this client's channel prevents local interleaving, not device changes or other tools'
writes. The operation repeats every captured entry: different live/SRVT-rule evidence yields ChangeObserved
and disqualify pending monitoring input. Identical answers still mean Unconfirmed, never an
atomic device image. Missing or malformed required recheck evidence disqualifies input too. ConfigurationId is a new
identity for every acquisition, not a device-provided version; StartedAtUtc and monotonic
Duration record the acquisition interval. The returned collections and bytes are immutable.
Replacing a monitor's snapshot begins a new evaluation period. None of these diagnostics is
safety approval.
Continuous passive SRDO and GFC monitoring¶
ForeignSrdoMonitor (#401) receives individual frames from a private RawCan subscription and
supervises independent SRDO pairs on one capture bus. Open one monitor per bus. It requires no
local CANopen node, NMT state, safety object dictionary or process-data sink. It has no send path:
no SDO, NMT, heartbeat, EMCY, SRDO or GFC is transmitted. Prepare configuration separately using
a known EDS/DCF mapping or an explicit read-only acquisition described above.
// SCT and SRVT are explicit observer limits, not a producer's refresh period or CRC default.
var input = ForeignSrdoConfiguration.FromSnapshot(image, number: 1,
validationTime: TimeSpan.FromMilliseconds(10),
safeguardCycleTime: TimeSpan.FromMilliseconds(100));
using var monitor = ForeignSrdoMonitor.Open(service, "adapter/channel-1", new[] { input });
var result = monitor.Poll();
foreach (var item in result.Observations)
Console.WriteLine($"{item.BusIdentity} {item.HostTimestamp}: {item.Status}, {item.CaptureQuality}");
foreach (var state in result.States)
Console.WriteLine($"SRDO {state.Configuration.Number}: current={state.IsCurrent}, age={state.Age}");
// If HasBacklog is true, poll again until caught up before displaying current values.
ForeignSrdoConfiguration copies and validates a byte-aligned mapping (up to eight bytes),
consecutive standard odd/even COB-IDs and positive observer limits (SRVT up to 255 ms, SCT up
to 65535 ms). Its provenance is mandatory. Invalid configuration throws before monitoring;
invalid replacement leaves the existing session intact. FromSnapshot requires an eligible
record and preserves the whole immutable acquisition, raw provenance and its configuration ID.
A receiver record supplies its consumer SCT/SRVT unless overridden. A producer record requires
both explicit observer limits: its refresh time and checksum SRVT/default are not inferred
as monitoring deadlines. Configuration node IDs associate a mapping; they do not authenticate
the sender of a received frame. Duplicate peer/number keys or overlapping COB-IDs on one bus
are rejected as ambiguous. An empty configuration still monitors GFCs.
The pairing rules follow the existing local consumer, using its inversion and mapping helpers:
an initial lone inverted frame is reported as StartupInverted, with the initial SCT still
running. A normal frame replaces a pending normal and restarts SRVT; values are unavailable
while waiting. Equal-length inverse pairs are decoded only if long enough for the mapping;
extra bytes within classic CAN's eight-byte limit participate in inversion but are not decoded.
Short pairs, mismatches, wrong order and invalid formats discard pending/current values without
resetting SCT. SRVT expires at the boundary; SCT also expires at its boundary and wins a tie
with that SRDO's SRVT. Expiry items retain the logical deadline timestamp. A fresh usable pair
recovers and restarts SCT. No received values are written to an object dictionary.
Timing uses CanFrameEvent.HostArrivalTimestamp, captured by RawCan before buffering, on the
same process's Stopwatch clock. Adapter ReceiveTimestamp is retained for trace correlation;
it is not used for arithmetic or comparisons across buses. Poll advances deadlines between
captured frames, then to the current clock only after draining. UI refresh rate or delayed
delivery therefore does not turn an on-time buffered pair into a late pair. A bounded batch
reports HasBacklog conservatively even if the final input exactly filled it, with no current
signals until a subsequent drain. Missing/zero, future, backward, or already-assessed late host
timestamps produce TimingUnavailable, discard pending/current values and suspend timing until
fresh input establishes a new baseline. This cannot measure unknown driver/adapter delays.
The new window also treats lone inverted frames as a mid-pair join (StartupInverted), after
either a gap or insufficient timing. It cannot assert a producer ordering fault when the normal
half may have been unobserved. Repeated inverted halves remain startup observations until a
pair is seen or a fresh window deadline expires; capture quality remains independently qualified.
States have AsOfTimestamp; retained instances are historical. Age describes the last valid
pair, including after a fault; Signals is empty unless the state is current. A fault never
silently reuses the last process value.
ReplaceConfigurations starts a fresh evaluation period and clears all previous values and
timers. Queued SRDO frames older than replacement are reported as BeforeEvaluationPeriod;
they cannot be decoded with the new mapping. Frames stamped exactly at replacement belong to
the new period; the host clock cannot distinguish events within one tick. Every observation
and state retains configuration/evaluation identities so an application can correlate changes.
With ILossAwareSubscription (#402), enqueue gaps and durable drop counts qualify capture
separately from SRDO status. A gap discards every pending pair/current value and starts a new
window; it cannot assemble a normal frame before a gap with an inverted frame after it. A
stable fresh complete pair can restore that SRDO window to Contiguous; the session trace
remains Incomplete, because lost GFCs or other frames cannot be recovered. A counter change
that cannot be located by sequence, or occurs during a poll, conservatively invalidates even
a pair reconstructed in that poll. A later stable fresh pair recovers. Counter saturation
does not defeat sequence checks. An externally supplied subscription must have one exclusive
reader from its creation; shared readers make gaps ambiguous. Legacy inputs retain Unknown
quality and a null drop count. Contiguous means no detected subscription loss, never proof
of complete driver or bus capture. An expiry reports absence in this capture window, never
a confirmed remote device failure, especially with incomplete input.
Each captured classic, base-ID 001h, non-RTR, non-error, zero-length GFC produces a distinct
GfcObserved trace item with bus identity and capture timestamps, including host echoes. CAN FD,
extended, RTR, error or non-empty candidates yield MalformedGfc. GFCs have no associated
configuration/node: the wire format carries no sender identity. They are independent of SRDO
pair state and never imply that a device entered its safe state. All items are returned directly
in bounded poll results; there is no secondary event buffer silently dropping repetitions.
Disposal stops analysis and closes only an owned subscription; the shared service stays alive.
The local consumer remains the device-role implementation with NMT, OD actuation and EMCY.
ObserveForeignSrdoAsync remains the existing one-pair decoder with optional SDO preparation
and caller-owned timing. The continuous monitor consumes already prepared input. These
diagnostic observations make no safety approval or SIL claim; unresolved interoperability
questions tracked in #289 remain open.
What this is not¶
This library is not developed to IEC 61508 / DIN V VDE 0801 and claims no safety integrity level. It implements the data transport of DSP 304 V1.0 — the frame pair, the timing checks, the objects, the checksum — and has not been run against a certified safety device or tester. The diverse redundancy of §9.5 (the pair "built by two different ways", the data "compared … in the application") and the safe state a safety controller enters are the device's to provide.
Layer setting services (CiA 305)¶
CanOpenLss.OpenMaster is an LSS master (FR-CO-047). It transmits on 7E5h and receives on
7E4h. Exactly one LSS master may be active in a CANopen network; this type does not police
other processes. Commands run one at a time. The master may share one ICanBusService with a
node in the same process that runs the LSS slave: it receives frames the adapter flags as this
host's echo, as the node and discovery do. It only transmits on 7E5h, so an echo on 7E4h is
always a slave's reply and never its own request. A confirmed service that draws no response within
LssMasterOptions.ResponseTimeout throws LssTimeoutException. Switch state selective, the
identify services and the Fastscan reset step treat that silence as "no slave matched".
Switch state global with LssState.Configuration puts every LSS slave into configuration. Use
it only when the network has a single LSS slave. Otherwise select one slave with
SwitchStateSelectiveAsync, or let Fastscan do it: a successful Fastscan leaves that one
unconfigured slave in configuration, and switch state selective is not required afterwards.
await using var master = CanOpenLss.OpenMaster(bus, new LssMasterOptions
{
ResponseTimeout = TimeSpan.FromSeconds(1),
FastscanTimeout = TimeSpan.FromMilliseconds(50),
});
var found = await master.FastscanAsync();
if (found.Succeeded)
{
var nodeId = await master.ConfigureNodeIdAsync(0x10);
if (nodeId.Succeeded)
await master.StoreConfigurationAsync();
await master.SwitchStateGlobalAsync(LssState.Waiting);
}
// Table 0 index 2 is 500 kbit/s. Both switch-delay periods elapse here.
// No host-switch callback is configured in this example, so omit the per-call bit rate.
await master.SwitchStateGlobalAsync(LssState.Configuration);
await master.ConfigureBitTimingAsync(LssBitTiming.Cia301Table, tableIndex: 2);
await master.ActivateBitTimingAsync(switchDelayMilliseconds: 100);
Before sending further commands, dispose this master and reopen the adapter at 500 kbit/s.
To retune the already-open channel between the two periods instead, configure BitRateSwitch
and pass bitsPerSecond to ActivateBitTimingAsync. A supplied rate requires that callback
on every activation path; a missing callback is rejected before any frame is sent.
ConfigureNodeIdAsync sends the value it is given, including values the protocol rejects, so
the slave's error code (LssCommand.NodeIdOutOfRange, or LssCommand.ManufacturerSpecific
with LssServiceResult.SpecError) can be observed. FFh is a legal pending node-id: the
slave is then not configured. Inquire node-id reports the active node-id, or the pending one
when the slave has no active node-id yet. The slave in this package's node never has such a
pending value: it activates a node-id in 1..127 as soon as configure node-id writes one, so
without an active node-id it reports FFh.
Fastscan walks vendor-id, product code, revision and serial. Bit 0 has priority over bit 1, so
the slave found is the numerically smallest address among the unconfigured slaves that take
part. A field can be scanned, matched (the number is already known) or skipped. The vendor-id
cannot be skipped, and at most two numbers can; a partial scan must still select one slave.
Skipping the serial number is allowed by CiA 305 §6.7.1. The last number that is not skipped is
confirmed with BitCheck 0, LSSSub naming that number and LSSNext 0, and per §6.7.3 that message
switches the identified slave into configuration whatever the LSSSub; the slave in this package's
node does. A slave that ties the switch to the serial number does not conform: it answers every
step and still stays in waiting, so the scan reports success and the services after it reach no
slave. Against such a slave, scan or match the serial number.
KnownHighBitCount checks a known prefix in one step and scans only the remaining bits.
Each Fastscan step occupies FastscanTimeout from the request, and FastscanCycleTime is an
extra gap before the next Fastscan request, including the reset of a new scan. A completed scan
returns without waiting for that gap. Identify waits out IdentificationTimeout the same way, so a
later slave is not left queued for the next command. Give the slave a node-id in 1..127
before the next Fastscan, or the same slave is found again.
The slave¶
CanOpenNodeOptions.Lss turns layer setting services on in the node (FR-CO-048). A device
description with LSS_Supported=1 does the same when Lss is left unset; an explicit
LssSlaveOptions.Enabled = false wins over the file. node.LssState() is waiting or
configuration. It is an extension, so ICanOpenNode itself did not grow a member.
The node-id passed to OpenNode is the persistent and pending node-id, unless
LssSlaveOptions.Store loads one: a stored node-id replaces it, and the argument is then only
validated. 0xFF is legal only while LSS is on: the node then stays in NMT Initializing, sends no
boot-up and serves no SDO, until configure node-id gives it a value in 1..127. That value becomes
active at once and the node boots, and it stays in LSS configuration. A node that already has an
active node-id keeps it until the next NMT reset communication, which copies the pending node-id
into the active one and retargets pre-defined COB-IDs, and the direction of SRDO 1 with its default
cycle time (EN 50325-5:2010 Table 5), that still name the old one; a direction of a committed SRDO
stays. Reset node copies
the persistent node-id back into the pending one first. Neither reset reloads the bit timing.
An identity change closes the transmit gate and drains old-ID reservations before publishing
the new node-id. Old queued SDO batches and heartbeat states are discarded; queued EMCYs
resolve their COB-ID after the change. A bit-rate switch already holding the gate stays silent.
ILssConfigurationStore is the only persistence. Without one, store answers "not supported".
1010h does not store the layer settings, and an LSS store does not rewrite the object
dictionary. Table 0 indexes follow BaudRate_* in the device description when the file names
any of them; otherwise every named CiA 301 rate is accepted. BitRateSwitch is called when the
pending timing becomes active; the callback can reconfigure the channel. The node stays silent
until both the second switch delay and the callback complete successfully, including for a zero
delay. While the node is active, failed or cancelled callbacks are reported on
BackgroundExceptionOccurred and keep the node silent until a later successful activation.
Repeated activations serialize the callbacks. An activation that arrives during the silence does not
end it: the node stays silent until that activation's second delay and callback have completed, and
the earlier activation's end of silence is dropped. An LSS command that arrives during the silence
is performed at once; its reply is held like the other frames that may not go out yet and sent once
the slave may transmit — every reply, not only the last one, and the replies keep the order of the
commands, across a further silence too and while the held ones are still going out.
A new activation requests cancellation of the previous callback as soon as it is received, including during the first switch delay. Disposal also requests cancellation. Neither waits for application cancellation handlers. The replacement callback gets a fresh token and starts only after the previous callback finishes. Callbacks must honor their token or implement their own timeout: a callback that ignores cancellation still delays later callbacks and can continue after disposal. Queued callbacks are not started after their cancellation is requested. The node never resumes transmission on an assumed switch completion or a timeout alone.
NMT, boot-up and heartbeat that come due in that window are held; cancelled NMT commands are
not sent, and heartbeat frames use the node-id active when they are sent.
The transmit gate closes before waiting for existing driver reservations to finish, so new
traffic cannot prolong the drain or reach the driver before the switch completes.
Periodic heartbeat ticks are coalesced while a send is outstanding, so recovery does not replay
a backlog. Disposal discards queued heartbeats.
A flying-master Reset Communication is applied only once its NMT frame has been sent. An SRDO
pair already reserved finishes before switching starts; pairs due during silence are dropped
as a unit. A GFC command that cannot be transmitted fails with CanOpenTransportException.
Other frames are not sent.
var store = new MyLssStore();
using var node = CanOpen.OpenNode(bus, nodeId: 0xFF, new CanOpenNodeOptions
{
Lss = new LssSlaveOptions { Store = store },
});
// node.State is Initializing and node.NodeId is 0xFF until a master configures 1..127.
Commissioning¶
LssCommissioning (FR-CO-049) runs the master as a commissioning sequence. A scan without a
node-id parks each unconfigured slave in configuration so Fastscan does not return it twice,
then switches every slave back to waiting. A scan that assigns node-ids switches every slave
to waiting first, then configures the slave Fastscan left in configuration. Switch state
selective is not repeated after Fastscan. The scan switches back to waiting after each slave,
unless activation already sent that frame: after both switch delays with ActivateBitRate, before
the first one ends without it (see Not built, and why for that departure from CiA 305 §6.4.4). Bit timing is activated
during a scan only when Limit is 1. A scan that can select more than one slave configures
the table and leaves the rate unchanged, so a slave that already has a node-id stays on the
bitrate the next Fastscan still uses. Activating one selectively switched slave is valid when
it is the only LSS slave, or every other node already runs the new rate. A slave that rejects
a service stops the batch; its result carries the code. Slaves configured earlier keep the
pending bit rate, and the stored rate when store already succeeded. This tool does not roll
them back to the rate the network is still using; the caller does that before a power cycle.
A confirmed service that times out stops the scan and returns the slaves that already received
a node-id, including the slave whose configure node-id succeeded before a later service timed out. Configure node-id writes the pending node-id. On a slave that already has an active
node-id it takes effect at the next NMT reset communication, which this tool does not send.
ConfigureSlaveAsync throws on a timeout; a previously confirmed pending node-id can still
be written. The exception identifies the service that timed out and preserves its stack trace.
Supplying ActivateBitRate requires the master's BitRateSwitch callback. Commissioning
checks activation arguments before selecting or writing any slave.
Configure, inquire and scan sequences sharing a master are serialized through their cleanup,
including calls from different LssCommissioning instances. Direct calls to that master must
not overlap a commissioning sequence.
Configured slaves do not take part in Fastscan. ReadIdentitiesAsync reads 1018h:01–04
from a node that has already booted. A missing later sub-index is 0. A timeout or any other
abort is not reported as a zero LSS number. Switching the slaves back to waiting after a
service is best-effort: if that frame fails, the configuration result is still returned. It is
not cut short, though: the release ignores the caller's token and has no time budget of its
own, so a cancelled or failed sequence returns only once that frame was sent or failed. It
first waits out whatever the master has reserved. After an abandoned activation that is both
switch-delay periods, up to 2 × 65 535 ms. Disposing the master ends that wait.
var tool = new LssCommissioning(master);
var scan = await tool.ScanUnconfiguredAsync(new LssUnconfiguredScan
{
AssignFromNodeId = 0x10,
Store = true,
});
// scan.Slaves lists each address and the node-id it was given. scan.Stop says why it ended.
var one = await tool.ConfigureSlaveAsync(scan.Slaves[0].Address, new LssSlaveConfiguration
{
BitTimingSelector = 0,
BitTimingIndex = 2,
ActivateDelayMilliseconds = 100,
ActivateBitRate = 500_000,
});
The sample samples/CanKit.Pro.Sample.CanOpenLss is that sequence as a command-line tool.
--demo needs no hardware. Activating bit timing still does not reconfigure the open channel.
Automatic bit-rate detection (CiA 801)¶
LssBitRateDetection (FR-CO-050) is the method behind table 0 index 9: a device that does not
know the bit rate of the bus finds it before it is given a node-id, by listening at each
candidate in turn. It is the listen-only approach of CiA 801 §5.2.3, Figure 1.
For each candidate the caller's factory opens the controller in listen-only mode, so the
search acknowledges nothing and transmits no error frame (§5.2.1). The detection then waits for
the first of: a received frame — the controller delivers one only at the right rate, so the
candidate is detected; enough error frames to rule the rate out (two by default, so a single
corrupted frame at the right rate does not move the search on, §5.2.3/§7); or the end of the
window. A window that ends with the controller no longer error-active rejects the candidate too,
for adapters that move BusState without raising an error frame.
// On CanKit, the factory opens each candidate listen-only, accepting every id.
var result = await LssBitRateDetection.DetectAsync(rate => CanBus.Open(endpoint, cfg =>
cfg.Baud(rate).SetWorkMode(ChannelWorkMode.ListenOnly).EnableErrorInfo()));
if (result.Detected)
{
// result.BitsPerSecond is the rate; result.TableIndex is its CiA 301 table index when
// the table lists it. Reopen the bus at that rate for normal operation.
}
The method needs traffic (§6): at least one frame has to be on the bus while the correct
candidate is open. A CANopen manager produces it with its heartbeat, with SYNC, or with SDO
reads of 1000h, 1001h or 1018h. The factory opens each candidate afresh because adapters
generally cannot change the bit rate of an open channel; the number of frames the search
consumes depends on how many candidates precede the right one and on the adapter's close/reopen
time (Figure 2). LssBitRateDetectionOptions sets the candidates and their order (500/250/125
kbit/s first by default), the per-candidate window, the error threshold and the number of
passes. RequireListenOnly only relaxes the detection's own check that each probe was opened
listen-only; it does not change how your factory opens the bus. For an adapter that accepts
listen-only but does not report the mode back, turning it off is enough and the factory keeps
opening listen-only. For an adapter that rejects listen-only but is silent in normal mode, turn
it off and have your factory open each probe in normal mode — which is reactive, so a wrong
candidate may emit error frames, against §5.2.1. The sample's detect verb does exactly this
under --allow-non-listen-only, and is otherwise this call on the command line, with --demo.
RequireListenOnly can only check what the bus reports, and a CanKit bus reports the work mode
the factory asked for; whether the controller is silent is up to the adapter. Of the CanKit 0.5.6
adapters whose listen-only path was read, SocketCAN (on a #netlink endpoint), PCAN, Vector, ZLG
and ControlCAN apply it, and the loopback adapter has no bus to disturb. Vector does so only when
the port gets init access to the channel: without it (another application owns the channel)
VectorBus skips its whole configuration, rate and output mode included, and only logs a warning,
so the bus still reports the requested mode and rate. The Kvaser adapter does
not: it accepts ListenOnly, reports it and advertises CanFeature.ListenOnly, but never switches
the controller's output to silent, so a probe would acknowledge frames at the right rate and send
error frames at a wrong one (#345;
upstream candidate).
While RequireListenOnly holds, the detection therefore refuses a Kvaser bus before it listens.
CiA 801 has no other software method for such a controller — §5.2.1 rules out testing the network
by transmitting, and §5.2.4 prescribes external hardware that holds the transmit line recessive,
after which the Figure 1 search runs unchanged. With that hardware, or on a bench where disturbing
the network is acceptable, turn RequireListenOnly off.
The single-dominant-bit timing of §5.2.2, the external AND/OR-gate variant of §5.2.4 for controllers without a listen-only mode, and the default fallback bit rate of §5.1 are not built; see Not built.
Flying master¶
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.
Boot-up¶
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. With a CiA 304 safety slave assigned there is no broadcast: at the same moment each seen slave gets an NMT Start of its own, a safety slave only after step D, and a keep-alive slave that never announces is not started (Verifying, and the boot-up). |
| 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.
Quick start¶
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 slaves up as an NMT master (PDOs flow in Operational only). Sending does not
// start *this* node by itself: it obeys the frame only if the adapter delivers its own transmits
// back (a loopback, or hardware in an echo mode), and not in Normal mode on a non-echoing adapter,
// so do not rely on it. This node enters Operational through the boot-up of an active flying master
// (bit 2 of 1F80h clear) or as the target of another master.
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.
Layout¶
CanKit.Pro.CANopen/
CanOpen.cs // factory
ICanOpenNode.cs // public contract
CanOpenNodeExtensions.cs // DisposeAsync for ICanOpenNode
CanOpenNodeProfile.cs // the role a node is opened as (device or tool)
CanOpenNode.cs // node core: subscription, dispatch, SDO client + server, NMT, heartbeat, EMCY
CanOpenNode.CommunicationProfile.cs // partial: the CiA 301 objects, their validation, power-on values, resets
CanOpenNode.DeviceDescription.cs // partial: applying an EDS/DCF to the dictionary, with its findings
CanOpenNode.Pdo.cs // partial: PDO engine — transmission types, inhibit time, event timer, RTR, mapping
CanOpenNode.PdoTiming.cs // partial: SYNC counter, cycle period, synchronous window, PDO deadline indications
CanOpenNode.Mpdo.cs // partial: multiplexed PDOs, SAM/DAM, scanner and dispatcher lists
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
CanOpenNode.Safety.cs // partial: CiA 304 objects, their validation, ICanOpenSafety, the SRDO send chain
CanOpenNode.PeerSafety.cs // partial: configuring (§9.2, as the CiA 319 configuration entity) and verifying (step D) a peer's safety parameters
CanOpenNode.ForeignSrdo.cs // partial: splitting a peer's SRDO pair
CanOpenNode.Lss.cs // partial: the CiA 305 LSS slave and the transmit gate it holds
CanOpenDeviceDescription.cs // a CiA 306 EDS/DCF, read with EdsDcfNet
CanOpenDiscovery.cs // listen-only discovery and the scan on request
ForeignPdo.cs // a peer's decoded PDO and its direction
CanOpenNodeOptions.cs
CanOpenCobId.cs // pre-defined connection set, COB-ID control bits, restricted CAN-IDs
CanOpenEvents.cs // event argument types
CanOpenTransportException.cs // a frame could not be handed to the bus
PeerSdoAccessException.cs // an SDO client transfer refused locally by the peer gate
ObjectDictionary.cs, OdEntry.cs
Nmt/NmtState.cs // NMT enum + command specifier
Nmt/FlyingMaster.cs // flying-master role and signal
Heartbeat/ // HeartbeatProducer and HeartbeatConsumer, independent of each other
Sdo/ // codec, abort codes, exception, block-transfer codec + mode enum
Pdo/PdoMapping.cs // mapping entries, transmission-type enums and byte constants
Pdo/PdoBits.cs // bit packing of mapped objects, least significant bit first
Pdo/ICanOpenPdo.cs // MPDO configuration and PDO monitoring contracts
Pdo/ICanOpenPdoMonitoring.cs
Emcy/EmcyMessage.cs // 8-byte encode/decode
Safety/ // CiA 304: SrdoEngine, SrdoCrc, SrdoFrames, SrdoRecords, ICanOpenSafety, value types
Lss/ // CiA 305: LSS master and slave options, frame codec, Fastscan;
// CiA 801 automatic bit-rate detection (LssBitRateDetection.cs)
Migrating from 1.2.x¶
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.
The object dictionary now drives the node¶
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.
Changes since 1.3.0¶
ConfigurePeerSafetyAsyncis the configuration entity of CiA 319 v2.0.0 (FR-CO-051): it reads the peer's1018h:01–03first and compares them with the bound file's own1018hentries, so a file that does not declare them with a value, contradicts them in[DeviceInfo]or declares them all 0 is refused before the first frame, and a peer that is another device ends the call with those entries as mismatches; every transfer the call makes whatever the peer answers passes the gate before the first frame, and those that depend on the peer's SRDO count pass it once the count is read, before the first write;PeerSafetyConfiguration.Storage(settable) asks for the step-5 store through1010h, reported inPeerSafetyResult.Stored, with a peer's abort inPeerSafetyResult.StorageAbort; and a tx SRDO whose record the file declares without a writable sub-index03hmust say 20 ms, which is written as optional where the file says nothing — a peer's0609 0011hor0601 0002his tolerated — and checksummed (§6.2).CanOpenNodeOptions.With(...)validates the copy it returns, as the node does when it is opened: an out-of-range value now throwsArgumentOutOfRangeExceptionfromWithrather than fromOpenNode. It gainedsrdoCountandenableChangeOfStateSrdo; the 1.3.0 signature is kept beside it, so compiled callers keep working.- Layer setting services (CiA 305) and CiA 801 bit-rate detection are new: the LSS master,
the LSS slave in the node, commissioning, and
LssBitRateDetection(see Layer setting services). - The node keeps
1001hfor the EMCYs it raises itself (#379): bits 4 and 0 while an RPDO, MPDO or SRDO length, MPDO destination, SYNC length or RPDO deadline error is active, and EMCY0000hwhen the last one ends (Error control). An EMCY sent withSendEmcyAsynccarries those bits too, ORed into the register the application passes. - The SYNC start value (
1800h:06) gates every synchronous TPDO (#358), acyclic (00h) and RTR-only (FCh) included, as CiA 301 §7.5.2.37 says. Before, those two used every SYNC.
Install¶
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 hardware
Dependencies: CanKit.Abstractions, EdsDcfNet, 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.