Status: E1–E4 done (July 2026). Remaining: the "later" items (DCF writing, CiA 311 XDD/XDC, CPJ).
- E1 — EDS/DCF reader + ObjectDictionary: done.
Formats/Eds/EdsReader(INI parser matching RawConfigParser semantics, Python int(s,0) numbers, $NODEID expressions, the misspelled [DeviceComissioning], CompactSubObj with lazy member synthesis, [xxxxName] renames, dummies, DataType>0x1B indirection, signed two's-complement limits) and theCanTools.CanOpenmodel (ObjectDictionary,OdVariable/OdArray/OdRecord,OdValue,CanOpenDataType,DeviceInformation). Tests ported from python-canopen's test_eds.py (43 tests); full model bulk-diffed against python-canopen for sample.eds (with and without node id) and datatypes.eds — zero differences (EdsBulkDiffTests, gated on CANOPEN_OD_DUMP). Deviations: invalid values are silently skipped (upstream logs warnings); [FileInfo] is not retained (needed only for the future writer); dotted string lookup ("A.B") is not implemented; values beyond 64 bits are rejected. Test data from python-canopen (MIT), see THIRD-PARTY-NOTICES.txt. - E2 — CobId + PdoDatabase projection: done.
CanOpen/CobId(function code / node id split, CiA 301 connection-set classification, compose) andCanOpen/PdoDatabase.Create(od)→ a plainDatabase: comm records 0x1400/0x1800 (ParameterValue beats DefaultValue, bit 31 disables — disabled PDOs are omitted from the Database, bit 30/29 dropped), mapping records 0x1600/0x1A00 (index<<16|sub<<8|bits, size masked to 7 bits, zero and unresolvable entries skipped without advancing the offset, dummies map as ordinary variables, record/array members get dotted "Parent.Name" names), contiguous little-endian offsets, identity conversions (EDS has no scaling). Tests replicate python-canopen's test_pdo_map_bit_mapping byte-exactly through the projection (fixture tests/files/eds/pdo_test.eds, hand-written) plus the empty-PDO behavior of sample.eds; verified against python-canopen itself running on the same fixture (offsets, names, enabled flags identical). Deviation: message names use upstream's TxPDO{n}_node{id} formula, which is only meaningful for connection-set COB-IDs. - E3 — frame codecs: done. Stateless parse/build per frame type in
CanOpen/:NmtMessage+NmtCommand/NmtState(with the command → target state map),HeartbeatMessage(node guarding toggle bit split off, boot-up = state 0),EmergencyMessage(CiA 301 error-class table and python-canopen's "Code 0x2001, Current" formatting),SyncMessage,TimeMessage(1984 epoch, ms-of-day + days, both directions), and theSdoFramerecord family: initiate upload/download requests/responses with the full e/s/n expedited and announced-size handling, segment frames (toggle/length/last),SdoAbortwith the completeSdoAbortCodetable, and classification-onlySdoBlockFrame. Golden vectors ported from python-canopen's test_nmt/ test_emcy/test_sync/test_time/test_sdo; the EMCY and abort description tables additionally diff-tested string-for-string against python-canopen (43 rows, zero differences). Deviations: builders throw (EncodeException/ ArgumentException) where upstream silently truncates (EMCY vendor data, oversized SDO payloads); SYNC/TIME get parsers upstream lacks (produce-only there); block transfer frames are classified but not decoded — mid-block data frames carry no command specifier at all, so per-frame classification is impossible and reassembly waits for the E4 interpreter. Toggle-bit alternation checking is likewise E4 state. - E4 — log-stream interpreter: done.
CanOpen/CanOpenLogInterpreterfoldsLogEntrystreams (anyLogParserformat) into aCanOpenEventrecord family: NMT commands, boot-ups, heartbeats (with per-node state tracking andIsStateChange), emergencies, SYNC/TIME, PDOs decoded through a projectedDatabase, and completed SDO transfers. SDO reassembly covers expedited, segmented and block transfers per node — block data frames are intercepted by phase (they carry a sequence number, not a command specifier), buffered per round and only committed on the receiver's ack, which makes seqno gaps and retransmitted rounds come out right; the end frame's n field trims the padding. Events fire on the server frame that completes the service (upload data / download ack). SDO conversations in the tests are the golden ones from python-canopen's test_sdo.py, including the block download/upload transcripts and a retransmission scenario. Deviations: block CRCs are not verified (a log records what happened either way), toggle-bit alternation is not enforced, and malformed protocol frames are skipped like unparseable log lines. There is no upstream oracle for the interpreter itself — python-canopen has no log reader — so this is original surface, anchored to upstream through the frame-level golden conversations.
This would be the repo's first deliberate step beyond upstream cantools, which has no CANopen support. The J1939 helpers set the precedent for protocol helpers; the mission stays the same: files and interpretation, no bus I/O. README and PORTING.md must mark this as an extension, not a port.
SdoClient reads and writes a remote node's object dictionary over any
ICanChannel. The core ships no channel implementation, so it stays
dependency-free; bring your own (e.g. the CanKit sample adapter).
using CanTools.CanOpen;
var client = new SdoClient(channel, nodeId: 0x0A);
byte[] raw = await client.UploadAsync(0x1018, 1); // read
await client.DownloadAsync(0x2000, 0, [0x2A, 0x00, 0x00, 0x00]); // write
uint deviceType = (uint)(await client.UploadAsync(0x1000, 0, CanOpenDataType.Unsigned32)).ToUInt64();Give the client an ObjectDictionary (loaded from the device's EDS/DCF) and it
takes each entry's index, subindex and CiA 301 data type from there, so calls name
a parameter instead of spelling out raw indices and type codes:
var od = EdsReader.LoadFile("device.eds", nodeId: 0x0A);
OdValue deviceType = await client.UploadAsync(od, "Device type"); // by name
OdValue param = await client.UploadAsync(od, 0x2000); // by index
await client.DownloadAsync(od, "Sample parameter", (OdValue)1234UL); // write by nameParameter names are vendor-specific, so read across arbitrary EDS files by index
(the standard 0x1000/0x1008 are universal, and CanOpenObjects names them);
names are handy when you author the dictionary yourself. There are matching
OdVariable overloads for when you already hold the entry.
The standard save/restore commands are one call each — they write the CiA 301 "save"/"load" signature to 0x1010/0x1011:
await client.DownloadAsync(od, "Sample parameter", (OdValue)1234UL);
await client.StoreParametersAsync(); // persist (0x1010, "save")
await client.RestoreDefaultParametersAsync(CanOpenParameterGroup.Communication);Expedited, segmented and block transfers are handled transparently; set
SdoClientOptions.EnableBlockTransfer to attempt block transfer with automatic
fallback to segmented.
For a runnable end-to-end tour — reading and writing an object dictionary over a
simulated in-process node, no hardware required — see
samples/CanTools.CanOpenSample
(dotnet run --project samples/CanTools.CanOpenSample).
DcfWriter writes a dictionary loaded from an EDS/DCF back to a DCF, preserving the
source file's structure, comments and ordering and layering in the commissioning
data and configured values. Set a node id, bitrate and ParameterValues, then write:
var od = EdsReader.LoadFile("device.eds");
od.NodeId = 0x0A;
od.Bitrate = 500_000;
od.SetValue("Producer heartbeat time", (OdValue)1000UL); // by name (top-level)
od.SetValue(0x1400, 1, (OdValue)0x40AUL); // by index/subindex (a member)
DcfWriter.WriteFile(od, "node_0A.dcf");An unchanged dictionary round-trips byte-for-byte. Values you did not touch are
written back verbatim, so $NODEID expressions and hex notation survive; only values
set via SetValue are reformatted. The node id is hex by default
(DcfWriterOptions.NodeIdFormat). This is the load → modify → write workflow;
authoring a dictionary from scratch is not supported (writing one without a source
file throws).
Research (July 2026) shows the .NET gap is real:
- EDS/DCF parsing: only eds-dcf-net (MIT, brand new, file round-tripping focus, no signal projection) and the GPL-3.0 CANopenEditor/libedssharp (off limits for us — never read its code).
- PDO mapping → signal decode is completely unserved in .NET. Interpreting a candump of a CANopen machine requires Python (python-canopen/canmatrix) today.
- Stateless SDO/NMT/EMCY frame codecs: nothing managed exists.
The unfair advantage: once an object dictionary projects to Message/Signal
objects, the existing decode engine, SignalValue, Database and the candump
logreader all work unchanged.
python-canopen (MIT) splits exactly
along our mission boundary: its objectdictionary/EDS parser and PdoMap are
bus-free, its Network/SDO client/LSS are not. The bus-free half is the behavioral
reference, diff-tested the same way cantools is for the other formats. Its MIT test
EDS files (and CANopenNode's Apache-2.0 profile EDS) are reusable as golden data
with the proper notices.
Two new areas, mirroring the existing layout:
src/CanTools/Formats/Eds/ EDS + DCF reader (CiA 306)
EdsReader.cs LoadFile/LoadString → ObjectDictionary
...tokenless INI parsing, $NODEID expression handling
src/CanTools/CanOpen/ protocol helpers (CiA 301)
ObjectDictionary.cs OdVariable / OdArray / OdRecord, CiA 301 data types
CobId.cs function code + node id split/compose, connection set
PdoDatabase.cs ObjectDictionary + node id → Database (the key feature)
NmtCommand.cs, Heartbeat.cs, Emergency.cs, SyncFrame.cs, TimeFrame.cs
SdoFrame.cs expedited transfers + abort codes; segment classification
CanOpenLogInterpreter.cs fold a LogEntry stream into typed events (phase E4)
var od = EdsReader.LoadFile("motor.eds"); // or .dcf
var entry = od[0x6041]; // OdVariable / OdArray / OdRecord
entry.DataType; // CiA 301 enum: Unsigned16, Real32, ...
entry.AccessType; // ReadOnly, ReadWrite, ...
entry.DefaultValue; entry.ParameterValue; // DCF ParameterValue wins
od.NodeId; // from [DeviceComissioning] (sic, one m)$NODEID+0x180 expressions stay unevaluated until a node id is supplied. The
misspelled [DeviceComissioning] section name is in the standard; accept it as-is.
var db = PdoDatabase.Create(od, nodeId: 5); // a plain CanTools Database
var decoded = db.DecodeMessage(0x185, frame); // TPDO1 of node 5, existing engineRules: COB-IDs from the communication parameters (0x1400/0x1800, honoring the
bit-31 invalid flag), layout from the mapping parameters (0x1600/0x1A00, entries
packed back-to-back little-endian), types/signedness/float from the mapped entries,
names Group.Variable like python-canopen. EDS carries no scale/offset/unit —
signals get identity conversions; callers can overlay their own Conversions.
Stateless, like J1939: parse/build NMT commands, heartbeat states, EMCY (with the
CiA 301 error-code table), SYNC, TIME, expedited SDO and SDO aborts. SDO
segmented/block transfers: per-frame classification is stateless; reassembly is
a stream fold (state, no I/O) and belongs in the log interpreter.
CanOpenLogInterpreter consumes LogEntry streams from the existing LogParser
and yields typed events: NMT state changes, emergencies, decoded PDOs, completed
SDO transfers. Zero I/O — same category as the logreader itself.
SDO client/server engines (timeouts, retries, block windows), NMT master, heartbeat monitoring, node guarding polling, SYNC production, LSS, node emulation, CiA 402 drive profiles. These need a bus and timing; they are a different library.
- E1 EDS/DCF reader + ObjectDictionary. Golden data: python-canopen test files
(MIT) + CANopenNode profile EDS (Apache 2.0, with notice). Diff-test against
python-canopen's
import_eds. - E2
CobId+PdoDatabaseprojection. Acceptance: decode a real candump of a CANopen node end-to-end with existing tooling. - E3 Frame codecs (NMT, heartbeat, EMCY, SYNC, TIME, SDO expedited/abort).
- E4 Log-stream interpreter (incl. segmented SDO reassembly).
- Later: DCF writing, CiA 311 XDD/XDC (XML twin of EDS, like KCD is to DBC), CPJ.
- "CANopen" is a CiA trademark: descriptive use in
CanTools.CanOpenmatches ecosystem practice (python-canopen, CANopenNode), but the README keeps the usual non-affiliation wording. Don't reproduce CiA spec text verbatim. - Write our own INI parsing (small, and the OD model must be shaped for signal projection, not file round-tripping); do not depend on or read GPL code.