From bf1816b7a870a75ffa00d8ff7b036ab7c8c458a9 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:19:01 +0200 Subject: [PATCH 1/3] docs(canopen): say when a bound device description may be edited in place A bound CanOpenDeviceDescription is the mutable EdsDcfNet model, and every reader of it - the peer-SDO gate on each SDO call, the loader, the foreign PDO and SRDO observers, a safety transaction's reading at its start - enumerates it without synchronization. The README said a bound instance may be edited in place and that the edit is seen from the next call, but not that a structural edit during one of those readings fails that call. #296. Decision (option c of #296): the rule stands and is stated - edit a bound description only while no call that reads it is running; a structural edit during one fails that call with the dictionary's InvalidOperationException, reported as the call's own failure, never a silent wrong reading. README (SDO client, configuring a peer), the Objects and BindPeerDeviceDescription XML docs and FR-CO-029 say so. An immutable description, edit then bind again, is #299 for the next major. Co-Authored-By: Claude Fable 5.1 --- docs/requirements/SRS-CanKit.Pro.md | 2 +- .../CanOpenDeviceDescription.cs | 8 +++++++- src/CanKit.Pro.CANopen/ICanOpenNode.cs | 5 ++++- src/CanKit.Pro.CANopen/README.md | 15 ++++++++++++++- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/requirements/SRS-CanKit.Pro.md b/docs/requirements/SRS-CanKit.Pro.md index 0c4160cc..b18be669 100644 --- a/docs/requirements/SRS-CanKit.Pro.md +++ b/docs/requirements/SRS-CanKit.Pro.md @@ -324,7 +324,7 @@ Ist-Zustand (09.10.2026): ausgeliefert als `CanKit.Pro.CANopen`. Der Zuschnitt d | FR-CO-026 | Das System MUSS jeden Eintrag einer Gerätebeschreibung, den es nicht wie beschrieben umsetzen kann, degradieren, die Degradation im OD korrigieren und in `ICanOpenNode.DeviceDescription` (`DeviceDescriptionReport`) je Eintrag mit Index, Sub-Index, Ergebnis, Begründung, beschriebenem Wert und — wo eine SDO-Regel entschieden hat — dem Abort-Code melden; nichts wird still übergangen. Ergebnisse: *Omitted* (nicht anlegbar: Datentyp ohne Entsprechung, fünftes PDO, unparsbarer Wert, nicht implementierter Sub-Index eines festen Records), *Corrected* (der Wert wurde von derselben Regel wie ein SDO-Download abgewiesen, die CiA-301-Vorgabe bleibt), *PdoDisabled* (das PDO bleibt zerstört, weil Kommunikationsrecord oder Mapping nicht anwendbar ist), *NotImplemented* (der Eintrag existiert als Datum ohne Verhalten, etwa 1012h oder ein abweichendes 1200h), *SuppliedDefault* (ein fehlendes Pflichtobjekt 1000h/1001h/1018h behält den Platzhalter). `IsExact` ist genau dann wahr, wenn es keine Meldung gibt. | Must | Unit-Test (`CanOpenDeviceDescriptionTests`): `quirky.eds` erzeugt je Fall die Meldung mit dem erwarteten Ergebnis und Abort-Code — 1018h fehlt (SuppliedDefault), 1800h:02 = F5h (Corrected, 0609 0030h, FEh bleibt), 1801h:01 mit Bit 29 (PdoDisabled), 1A02h:01 auf ein nicht existierendes Objekt (PdoDisabled, 0602 0000h), 1804h/1A04h (Omitted), 1012h (NotImplemented), INTEGER24 (Omitted), doppelte Node-ID in 1016h (Corrected, 0604 0043h), abweichendes 1200h:01 (NotImplemented), 1010h:02 (Omitted) — und das OD trägt jeweils den korrigierten Wert. | Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 2); Abort-Codes nach CiA 301 §7.2.4.3.17; #132 | | FR-CO-027 | Das System MUSS die Zugriffsrechte, die eine Gerätebeschreibung für die PDO-Records 1400h–1403h, 1600h–1603h, 1800h–1803h und 1A00h–1A03h angibt, auf dem Bus durchsetzen — ein als `rw` beschriebenes Record ist per SDO schreibbar, auch ohne die Option `WritableCommunicationParameters`, ein als `ro` beschriebenes Objekt bleibt `ro` (0601 0002h) — und das `PDOMapping`-Attribut der Datei als Mappbarkeit des Objekts übernehmen, so dass ein Objekt mit `PDOMapping=0` beim Mapping nach FR-CO-020 mit 0604 0041h abgewiesen wird. | Must | Virtual-Loopback-Integrationstest (`CanOpenDeviceDescriptionTests`): mit `device.eds` und ohne die Option schreibt ein Master 1800h:01 und 1A00h:00/01 per Re-Mapping-Prozedur und liest 1A00h:01 zurück; 2002h (`ro`) → 0601 0002h; `PDOMapping=0` von 2002h steht als `PdoMappable = false` im OD, `PDOMapping=1` von 2000h als `true`. | Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 13, 26); CiA 301 §7.5.2.36; #132 | | FR-CO-028 | Mit einer Gerätebeschreibung MUSS der Knoten deren Werte als Einschaltwerte führen: „load“ (1011h:01, `RestoreDefaultParameters`) und ein NMT Reset Communication stellen 1000h–1FFFh auf die beschriebenen — oder die seit dem letzten „save“ gespeicherten — Werte zurück, ein NMT Reset Node zusätzlich die Applikationsobjekte der Datei; Reset Communication lässt die Applikationsobjekte unverändert. | Must | Virtual-Loopback-Integrationstest (`CanOpenDeviceDescriptionTests`): nach lokalem Schreiben von 2000h und 1017h liefert Reset Communication 1017h = 200 (Datei) und 2000h unverändert, Reset Node 2000h = 1234h (Datei); „save“ nach 1017h = 300, dann „load“ und Reset Communication → 1017h = 200. | CiA 301 §7.3.2.2.1 (Einschaltwerte), §7.5.2.14 („load“), §7.2.8.2.1.5/§7.2.8.2.1.6 (Reset Node / Reset Communication); Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 1); #132 | -| FR-CO-029 | Das System MUSS jeden SDO-Client-Up- und -Download vor dem Senden gegen die Gerätebeschreibung prüfen, die für den Server gebunden ist (`BindPeerDeviceDescription`, EDS oder DCF): mit gebundener Datei nur (Index, Sub-Index)-Paare, die sie führt — 1000h, 1001h und 1018h eingeschlossen —, ohne Datei nur 1000h:00, 1001h:00 und 1018h:00–04h. Einzige Ausnahme (CiA DSP 304 §8.4.2.2, 13FFh:00 = Anzahl der SRDOs): führt die Datei SRDO-Records bis höchstens N, gelten Sub-Index 00h–06h von 1301h–(1300h + N) und 00h–10h von 1381h–(1380h + N) als geführt, so wie ein Gerät sie beim Laden der Datei bereitstellt; sonst nichts, und ohne SRDO-Record in der Datei keine. Eine Verweigerung MUSS als `PeerSdoAccessException` vor jedem SDO-Frame gemeldet werden. Eine DCF, die für eine andere Node-ID kommissioniert ist, MUSS mit `ArgumentException` abgewiesen werden und eine bestehende Bindung stehen lassen. | Must | Virtual-Loopback-Integrationstest (`CanOpenPeerSdoGateTests`): ein gelistetes Objekt wird übertragen, ein nicht gelistetes vor dem ersten Frame abgewiesen; ohne Datei gehen die drei Basisobjekte durch, 1003h und 1018h:05 nicht; eine Datei ohne 1000h verweigert 1000h; eine DCF für eine andere Node-ID wirft und ersetzt die Bindung nicht; die SRDO-Records bis zum höchsten der Datei gehen durch, darüber, jenseits Sub-Index 6/16 und ohne SRDO-Record in der Datei nicht (`CanOpenPeerSafetyTests.The_Gate_Implies_The_Srdo_Records_Up_To_The_Highest_Declared_One`, `Without_An_Srdo_Record_In_The_File_The_Gate_Implies_None`), und ein Gerät mit einer Lücke in den SRDO-Nummern wird konfiguriert, verifiziert und im Boot-up gestartet (`A_Device_Whose_File_Leaves_An_Srdo_Number_Out_Is_Configured_And_Verified`, `CanOpenSafetyBootUpTests.A_Slave_Whose_File_Leaves_An_Srdo_Number_Out_Is_Verified_And_Started`). | Entscheidung 1 (Scope-Dokument 2026-09-26); #174 | +| FR-CO-029 | Das System MUSS jeden SDO-Client-Up- und -Download vor dem Senden gegen die Gerätebeschreibung prüfen, die für den Server gebunden ist (`BindPeerDeviceDescription`, EDS oder DCF): mit gebundener Datei nur (Index, Sub-Index)-Paare, die sie führt — 1000h, 1001h und 1018h eingeschlossen —, ohne Datei nur 1000h:00, 1001h:00 und 1018h:00–04h. Einzige Ausnahme (CiA DSP 304 §8.4.2.2, 13FFh:00 = Anzahl der SRDOs): führt die Datei SRDO-Records bis höchstens N, gelten Sub-Index 00h–06h von 1301h–(1300h + N) und 00h–10h von 1381h–(1380h + N) als geführt, so wie ein Gerät sie beim Laden der Datei bereitstellt; sonst nichts, und ohne SRDO-Record in der Datei keine. Eine Verweigerung MUSS als `PeerSdoAccessException` vor jedem SDO-Frame gemeldet werden. Eine DCF, die für eine andere Node-ID kommissioniert ist, MUSS mit `ArgumentException` abgewiesen werden und eine bestehende Bindung stehen lassen. Die gebundene Beschreibung ist veränderlich (`Objects`, das EdsDcfNet-Modell) und wird von jedem Leser — dem Tor bei jedem Aufruf, dem Loader, den Beobachtern, einer Safety-Transaktion bei ihrer Lesung zu Beginn — ohne Synchronisation gelesen: ein Ändern an Ort und Stelle wirkt ab dem nächsten Aufruf, ein Struktur-Edit während eines lesenden Aufrufs lässt diesen Aufruf mit der `InvalidOperationException` des Dictionaries scheitern; eine gebundene Beschreibung wird daher nur geändert, solange kein lesender Aufruf läuft (dokumentierte Einschränkung, #296; unveränderliche Beschreibung beim nächsten Major, #299). | Must | Virtual-Loopback-Integrationstest (`CanOpenPeerSdoGateTests`): ein gelistetes Objekt wird übertragen, ein nicht gelistetes vor dem ersten Frame abgewiesen; ohne Datei gehen die drei Basisobjekte durch, 1003h und 1018h:05 nicht; eine Datei ohne 1000h verweigert 1000h; eine DCF für eine andere Node-ID wirft und ersetzt die Bindung nicht; die SRDO-Records bis zum höchsten der Datei gehen durch, darüber, jenseits Sub-Index 6/16 und ohne SRDO-Record in der Datei nicht (`CanOpenPeerSafetyTests.The_Gate_Implies_The_Srdo_Records_Up_To_The_Highest_Declared_One`, `Without_An_Srdo_Record_In_The_File_The_Gate_Implies_None`), und ein Gerät mit einer Lücke in den SRDO-Nummern wird konfiguriert, verifiziert und im Boot-up gestartet (`A_Device_Whose_File_Leaves_An_Srdo_Number_Out_Is_Configured_And_Verified`, `CanOpenSafetyBootUpTests.A_Slave_Whose_File_Leaves_An_Srdo_Number_Out_Is_Verified_And_Started`). | Entscheidung 1 (Scope-Dokument 2026-09-26); #174 | | FR-CO-030 | Das System MUSS mit `ObserveForeignPdoAsync` die Nutzlast eines PDO eines anderen Knotens in eine Senke des Aufrufers zerlegen und dabei das eigene OD nicht schreiben. COB-ID (1400h:01 / 1800h:01) und Mapping (1600h–1603h / 1A00h–1A03h) MÜSSEN live über den SDO-Client gelesen werden, also durch das Tor aus FR-CO-029; bricht das Live-Lesen ab, läuft es in die Zeit, verweigert das Tor das Paar, ist der SDO-Kanal des Knotens zu Beginn der Beobachtung belegt oder liefert es kein verwendbares Wort — beim Mapping auch einen Wert anderer Breite als sein Typ: Sub-Index 00h genau ein Byte (UNSIGNED8), ein Eintrag genau vier (UNSIGNED32), nicht auf die niedrigen Bytes gekürzt —, MUSS der Eintrag der an den Aufruf übergebenen EDS/DCF gelten (bei einer DCF der `ParameterValue`). Eine live gelesene COB-ID mit gesetztem Bit 31 wird nicht durch die Datei ersetzt, ein abgebrochener Aufruf fällt nicht auf die Datei zurück; eine unbekannte COB-ID, ein ungültiges PDO und eine eingeschränkte CAN-ID werden nicht zerlegt; eine Nutzlast kürzer als das Mapping schreibt nichts. Die Live-Lesungen einer Beobachtung — COB-IDs, Mapping — sind eine Momentaufnahme des Peers: der Beobachter nimmt den SDO-Kanal des Peers ohne zu warten und hält ihn bis zum Ende, sodass eine währenddessen startende Safety-Transaktion auf ihn wartet (wie auf jeden laufenden Aufruf) und ein anderer SDO-Aufruf dieses Knotens an den Peer als „already in flight“ abgewiesen wird; ein zu Beginn belegter Kanal macht jede Live-Lesung dieser Beobachtung zu „already in flight“, die ganze Momentaufnahme kommt dann aus der Datei — eine live gelesene COB-ID wird nie mit dem Datei-Mapping kombiniert, weil ein Aufrufer dazwischenkam (#295). | Must | Virtual-Loopback-Integrationstest (`CanOpenForeignPdoObserveTests`): Live-Mapping zerlegt ein TPDO ohne OD-Schreibzugriff; SDO-Abbruch, Zeitüberschreitung, Tor-Verweigerung und laufender Transfer nehmen die Datei, ebenso ein Zähler aus zwei Bytes und ein Eintrag aus drei oder fünf Bytes (`A_live_read_that_is_not_a_usable_COB_ID_or_mapping_falls_back_to_the_file`); eine live ungültige COB-ID bleibt ungültig; kurze Nutzlast, unbekannte und eingeschränkte COB-ID liefern kein Signal; ein Aufruf, der zwischen COB-ID- und Mapping-Lesung dazwischenkommt, wird als „already in flight“ abgewiesen und die Momentaufnahme bleibt live (`A_call_cutting_in_mid_snapshot_is_refused_and_the_snapshot_stays_live`). | Entscheidung 2 und Frage 3 (Scope-Dokument 2026-09-26); #163 | | FR-CO-031 | Das System MUSS die Wahl des aktiven NMT-Masters nach CiA 302-2 Version 4.1.0 (NMT flying master, 1F90h) ausführen: Teilnahme über 1F80h Bit 0 und Bit 5 bzw. `StartFlyingMaster`, Anfrage auf 073h, Antwort und Anspruch auf 071h (Priorität, Node-ID), Zeitschlitz-Wettlauf auf 072h mit `Priorität × 1F90h:04 + Node-ID × 1F90h:05`, Neuwahl auf 076h. Eine bessere oder gleiche Priorität setzt diesen Knoten auf Standby, ohne einen aktiven gleichrangigen Master abzusetzen; bei gleicher Priorität gewinnt die kleinere Node-ID; nach der ersten, kalten Wahl ohne Antwort MUSS Reset Communication an alle gehen und warm neu gewählt werden; der Verlust des Heartbeats des aktiven Masters MUSS eine warme Wahl auslösen. Priorität über 2, Device-Zeitschlitz 0 und ein Zeitschlitzpaar, das die Prioritäten nicht trennt (1F90h:04 ≤ 127 × 1F90h:05), MÜSSEN mit 0609 0030h abgewiesen werden. | Must | Virtual-Loopback-Integrationstest mit virtueller Uhr (`CanOpenFlyingMasterTests`, `CanOpenFlyingMasterGapTests`): allein am Bus gewinnt der Knoten nach dem kalten Reset; schlechtere Priorität weicht, bessere erzwingt den Neustart und gewinnt; gleicher Anspruch setzt den aktiven Master nicht ab; gleiche Priorität → kleinere Node-ID; Heartbeat-Verlust startet eine warme Wahl; ungültige 1F90h-Werte → 0609 0030h. | Entscheidung 4 und Frage 4 (Scope-Dokument 2026-09-26); CiA 302-2 v4.1.0 (öffentliche Beschreibung, Mitgliedstext nicht im Repository); #164 | | FR-CO-032 | Als aktiver Master MUSS das System die Slaves aus 1F81h hochfahren: Reset Communication je Slave an dessen Node-ID (nicht bei Bit 4 keep-alive), NMT Start nach seinem ersten Heartbeat oder Boot-up (Bit 2), Pflicht-Slaves (Bit 3) vor dem eigenen Operational und vor einem gemeinsamen Start (1F80h Bit 1); 1F80h Bit 2 hält den eigenen Zustand, Bit 3 unterdrückt das Hochfahren, Bit 4 und Bit 6 wählen die Reaktion auf die Boot-Zeitüberschreitung 1F89h (Reset Node bzw. Stop an alle, sonst Reset Node an den fehlenden Slave). 1F82h MUSS den zuletzt gesehenen Zustand je Node-ID führen; ein Schreibzugriff darauf fordert als aktiver Master das NMT-Kommando an und wird sonst mit 0800 0022h abgewiesen. Ein Tool-Profil beginnt mit gesetzten Bits 2 und 3. | Must | Virtual-Loopback-Integrationstest mit virtueller Uhr (`CanOpenFlyingMasterTests`, `CanOpenFlyingMasterGapTests`): zugewiesener Slave wird zurückgesetzt, dann gestartet und sein Zustand in 1F82h geführt; Bit 3 lässt Slaves unberührt; ein nie antwortender Pflicht-Slave läuft in die Zeit, Bit 4 und Bit 6 wirken; 1F82h-Anforderung nur als aktiver Master; ein Tool startet weder sich noch Slaves, bis die Bits gelöscht sind. | Frage 5 (Scope-Dokument 2026-09-26); CiA 302-2 v4.1.0 (1F80h, 1F81h, 1F82h, 1F89h); #164 | diff --git a/src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs b/src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs index 0b0fafc8..e8e4daa7 100644 --- a/src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs +++ b/src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs @@ -45,7 +45,13 @@ private CanOpenDeviceDescription(ElectronicDataSheet? eds, DeviceConfigurationFi public DeviceConfigurationFile? Dcf { get; } /// The object dictionary the description declares (the EdsDcfNet model, not the - /// node's ). + /// node's ). Mutable: an edit in place is + /// seen from the next call that reads the description. Nothing synchronizes those readers — + /// the peer-SDO gate on every SDO call to the peer this description is bound for, the loader, + /// the foreign PDO and SRDO observers, a safety transaction's reading at its start — with an + /// edit, so edit only while no such call is running: a structural edit (an object or a + /// sub-object added or removed) during one fails that call with the dictionary's + /// , reported as that call's failure. public EdsDcfNet.Models.ObjectDictionary Objects => Eds?.ObjectDictionary ?? Dcf!.ObjectDictionary; /// The device information section of the description. diff --git a/src/CanKit.Pro.CANopen/ICanOpenNode.cs b/src/CanKit.Pro.CANopen/ICanOpenNode.cs index 62ba8601..93a50b2f 100644 --- a/src/CanKit.Pro.CANopen/ICanOpenNode.cs +++ b/src/CanKit.Pro.CANopen/ICanOpenNode.cs @@ -299,7 +299,10 @@ Task SendEmcyAsync(ushort errorCode, byte errorRegister, /// Binds the EDS or DCF of remote node . Client SDO transfers to /// that node then proceed only for (index, sub-index) pairs the description declares /// (). Replaces a description already bound - /// for the same node-id. The description is read at the start of each transfer. + /// for the same node-id. The description is read at the start of each transfer, without + /// synchronization against an edit of the bound instance in place + /// (): edit it only while no call that reads + /// it is running, or bind another one. /// /// /// is a DCF whose commissioned node-id is not diff --git a/src/CanKit.Pro.CANopen/README.md b/src/CanKit.Pro.CANopen/README.md index f8e466e8..1e0bcfef 100644 --- a/src/CanKit.Pro.CANopen/README.md +++ b/src/CanKit.Pro.CANopen/README.md @@ -291,6 +291,19 @@ has no commissioned node-id and may be bound to any server. `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. A structural edit (an object or a sub-object added or removed) during such a call fails +that call: the EdsDcfNet dictionary throws `InvalidOperationException` from its enumeration, and +the call reports it as its own failure — an SDO call fails, a safety verification is not +verified, a boot halts at that slave. A value edit at worst gives that call the old or the new +value. The failure is loud, never a silent wrong reading that passes (#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 @@ -809,7 +822,7 @@ result too. An SDO abort, a timeout or a refusal by the peer-SDO gate propagates 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 and the gate of each of its transfers read a snapshot taken then — the declared pairs of the safety area 1300h–13FFh, 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. 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. +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 and the gate of each of its transfers read a snapshot taken then — the declared pairs of the safety area 1300h–13FFh, 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](#sdo-client-and-a-peers-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 From b017d76f50ba391625a582436cd1d7a881af63cc Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:36:19 +0200 Subject: [PATCH 2/3] docs(canopen): an edit during a reading of a bound description is unsupported, not loud bf1816b promised that a structural edit during a reading always fails that reading with the dictionary's InvalidOperationException. Codex: only an enumerating reader (the loader, a safety transaction's reading) can fail that way; the observers' file fallback and the gate's Contains look objects up one at a time around their own awaits, so an edit between two lookups gives a part-old, part-new reading or no match, with no error - and a Dictionary read concurrent with a write is unspecified anyway. README, the Objects XML doc and FR-CO-029 now say the edit is unsupported and what the call sees is unspecified, naming both outcomes. The rule itself is unchanged: edit only while no reading call runs. Co-Authored-By: Claude Fable 5.1 --- docs/requirements/SRS-CanKit.Pro.md | 2 +- src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs | 9 ++++++--- src/CanKit.Pro.CANopen/README.md | 14 ++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/requirements/SRS-CanKit.Pro.md b/docs/requirements/SRS-CanKit.Pro.md index b18be669..aa2b375b 100644 --- a/docs/requirements/SRS-CanKit.Pro.md +++ b/docs/requirements/SRS-CanKit.Pro.md @@ -324,7 +324,7 @@ Ist-Zustand (09.10.2026): ausgeliefert als `CanKit.Pro.CANopen`. Der Zuschnitt d | FR-CO-026 | Das System MUSS jeden Eintrag einer Gerätebeschreibung, den es nicht wie beschrieben umsetzen kann, degradieren, die Degradation im OD korrigieren und in `ICanOpenNode.DeviceDescription` (`DeviceDescriptionReport`) je Eintrag mit Index, Sub-Index, Ergebnis, Begründung, beschriebenem Wert und — wo eine SDO-Regel entschieden hat — dem Abort-Code melden; nichts wird still übergangen. Ergebnisse: *Omitted* (nicht anlegbar: Datentyp ohne Entsprechung, fünftes PDO, unparsbarer Wert, nicht implementierter Sub-Index eines festen Records), *Corrected* (der Wert wurde von derselben Regel wie ein SDO-Download abgewiesen, die CiA-301-Vorgabe bleibt), *PdoDisabled* (das PDO bleibt zerstört, weil Kommunikationsrecord oder Mapping nicht anwendbar ist), *NotImplemented* (der Eintrag existiert als Datum ohne Verhalten, etwa 1012h oder ein abweichendes 1200h), *SuppliedDefault* (ein fehlendes Pflichtobjekt 1000h/1001h/1018h behält den Platzhalter). `IsExact` ist genau dann wahr, wenn es keine Meldung gibt. | Must | Unit-Test (`CanOpenDeviceDescriptionTests`): `quirky.eds` erzeugt je Fall die Meldung mit dem erwarteten Ergebnis und Abort-Code — 1018h fehlt (SuppliedDefault), 1800h:02 = F5h (Corrected, 0609 0030h, FEh bleibt), 1801h:01 mit Bit 29 (PdoDisabled), 1A02h:01 auf ein nicht existierendes Objekt (PdoDisabled, 0602 0000h), 1804h/1A04h (Omitted), 1012h (NotImplemented), INTEGER24 (Omitted), doppelte Node-ID in 1016h (Corrected, 0604 0043h), abweichendes 1200h:01 (NotImplemented), 1010h:02 (Omitted) — und das OD trägt jeweils den korrigierten Wert. | Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 2); Abort-Codes nach CiA 301 §7.2.4.3.17; #132 | | FR-CO-027 | Das System MUSS die Zugriffsrechte, die eine Gerätebeschreibung für die PDO-Records 1400h–1403h, 1600h–1603h, 1800h–1803h und 1A00h–1A03h angibt, auf dem Bus durchsetzen — ein als `rw` beschriebenes Record ist per SDO schreibbar, auch ohne die Option `WritableCommunicationParameters`, ein als `ro` beschriebenes Objekt bleibt `ro` (0601 0002h) — und das `PDOMapping`-Attribut der Datei als Mappbarkeit des Objekts übernehmen, so dass ein Objekt mit `PDOMapping=0` beim Mapping nach FR-CO-020 mit 0604 0041h abgewiesen wird. | Must | Virtual-Loopback-Integrationstest (`CanOpenDeviceDescriptionTests`): mit `device.eds` und ohne die Option schreibt ein Master 1800h:01 und 1A00h:00/01 per Re-Mapping-Prozedur und liest 1A00h:01 zurück; 2002h (`ro`) → 0601 0002h; `PDOMapping=0` von 2002h steht als `PdoMappable = false` im OD, `PDOMapping=1` von 2000h als `true`. | Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 13, 26); CiA 301 §7.5.2.36; #132 | | FR-CO-028 | Mit einer Gerätebeschreibung MUSS der Knoten deren Werte als Einschaltwerte führen: „load“ (1011h:01, `RestoreDefaultParameters`) und ein NMT Reset Communication stellen 1000h–1FFFh auf die beschriebenen — oder die seit dem letzten „save“ gespeicherten — Werte zurück, ein NMT Reset Node zusätzlich die Applikationsobjekte der Datei; Reset Communication lässt die Applikationsobjekte unverändert. | Must | Virtual-Loopback-Integrationstest (`CanOpenDeviceDescriptionTests`): nach lokalem Schreiben von 2000h und 1017h liefert Reset Communication 1017h = 200 (Datei) und 2000h unverändert, Reset Node 2000h = 1234h (Datei); „save“ nach 1017h = 300, dann „load“ und Reset Communication → 1017h = 200. | CiA 301 §7.3.2.2.1 (Einschaltwerte), §7.5.2.14 („load“), §7.2.8.2.1.5/§7.2.8.2.1.6 (Reset Node / Reset Communication); Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 1); #132 | -| FR-CO-029 | Das System MUSS jeden SDO-Client-Up- und -Download vor dem Senden gegen die Gerätebeschreibung prüfen, die für den Server gebunden ist (`BindPeerDeviceDescription`, EDS oder DCF): mit gebundener Datei nur (Index, Sub-Index)-Paare, die sie führt — 1000h, 1001h und 1018h eingeschlossen —, ohne Datei nur 1000h:00, 1001h:00 und 1018h:00–04h. Einzige Ausnahme (CiA DSP 304 §8.4.2.2, 13FFh:00 = Anzahl der SRDOs): führt die Datei SRDO-Records bis höchstens N, gelten Sub-Index 00h–06h von 1301h–(1300h + N) und 00h–10h von 1381h–(1380h + N) als geführt, so wie ein Gerät sie beim Laden der Datei bereitstellt; sonst nichts, und ohne SRDO-Record in der Datei keine. Eine Verweigerung MUSS als `PeerSdoAccessException` vor jedem SDO-Frame gemeldet werden. Eine DCF, die für eine andere Node-ID kommissioniert ist, MUSS mit `ArgumentException` abgewiesen werden und eine bestehende Bindung stehen lassen. Die gebundene Beschreibung ist veränderlich (`Objects`, das EdsDcfNet-Modell) und wird von jedem Leser — dem Tor bei jedem Aufruf, dem Loader, den Beobachtern, einer Safety-Transaktion bei ihrer Lesung zu Beginn — ohne Synchronisation gelesen: ein Ändern an Ort und Stelle wirkt ab dem nächsten Aufruf, ein Struktur-Edit während eines lesenden Aufrufs lässt diesen Aufruf mit der `InvalidOperationException` des Dictionaries scheitern; eine gebundene Beschreibung wird daher nur geändert, solange kein lesender Aufruf läuft (dokumentierte Einschränkung, #296; unveränderliche Beschreibung beim nächsten Major, #299). | Must | Virtual-Loopback-Integrationstest (`CanOpenPeerSdoGateTests`): ein gelistetes Objekt wird übertragen, ein nicht gelistetes vor dem ersten Frame abgewiesen; ohne Datei gehen die drei Basisobjekte durch, 1003h und 1018h:05 nicht; eine Datei ohne 1000h verweigert 1000h; eine DCF für eine andere Node-ID wirft und ersetzt die Bindung nicht; die SRDO-Records bis zum höchsten der Datei gehen durch, darüber, jenseits Sub-Index 6/16 und ohne SRDO-Record in der Datei nicht (`CanOpenPeerSafetyTests.The_Gate_Implies_The_Srdo_Records_Up_To_The_Highest_Declared_One`, `Without_An_Srdo_Record_In_The_File_The_Gate_Implies_None`), und ein Gerät mit einer Lücke in den SRDO-Nummern wird konfiguriert, verifiziert und im Boot-up gestartet (`A_Device_Whose_File_Leaves_An_Srdo_Number_Out_Is_Configured_And_Verified`, `CanOpenSafetyBootUpTests.A_Slave_Whose_File_Leaves_An_Srdo_Number_Out_Is_Verified_And_Started`). | Entscheidung 1 (Scope-Dokument 2026-09-26); #174 | +| FR-CO-029 | Das System MUSS jeden SDO-Client-Up- und -Download vor dem Senden gegen die Gerätebeschreibung prüfen, die für den Server gebunden ist (`BindPeerDeviceDescription`, EDS oder DCF): mit gebundener Datei nur (Index, Sub-Index)-Paare, die sie führt — 1000h, 1001h und 1018h eingeschlossen —, ohne Datei nur 1000h:00, 1001h:00 und 1018h:00–04h. Einzige Ausnahme (CiA DSP 304 §8.4.2.2, 13FFh:00 = Anzahl der SRDOs): führt die Datei SRDO-Records bis höchstens N, gelten Sub-Index 00h–06h von 1301h–(1300h + N) und 00h–10h von 1381h–(1380h + N) als geführt, so wie ein Gerät sie beim Laden der Datei bereitstellt; sonst nichts, und ohne SRDO-Record in der Datei keine. Eine Verweigerung MUSS als `PeerSdoAccessException` vor jedem SDO-Frame gemeldet werden. Eine DCF, die für eine andere Node-ID kommissioniert ist, MUSS mit `ArgumentException` abgewiesen werden und eine bestehende Bindung stehen lassen. Die gebundene Beschreibung ist veränderlich (`Objects`, das EdsDcfNet-Modell) und wird von jedem Leser — dem Tor bei jedem Aufruf, dem Loader, den Beobachtern, einer Safety-Transaktion bei ihrer Lesung zu Beginn — ohne Synchronisation gelesen: ein Ändern an Ort und Stelle wirkt ab dem nächsten Aufruf; ein Ändern während eines lesenden Aufrufs wird nicht unterstützt, und was dieser Aufruf dann sieht, ist unbestimmt — die EdsDcfNet-Dictionaries sind für ein Lesen parallel zum Schreiben nicht sicher: ein enumerierender Leser (Loader, Lesung einer Safety-Transaktion) kann mit der `InvalidOperationException` des Dictionaries scheitern, gemeldet als Fehler dieses Aufrufs, ein Leser mit Einzelzugriffen um eigene Awaits (Datei-Rückfall der Beobachter, `Contains` des Tors) kann aus einer teils alten, teils neuen Beschreibung antworten oder keinen Treffer melden, ohne Fehler; eine gebundene Beschreibung wird daher nur geändert, solange kein lesender Aufruf läuft (dokumentierte Einschränkung, #296; unveränderliche Beschreibung beim nächsten Major, #299). | Must | Virtual-Loopback-Integrationstest (`CanOpenPeerSdoGateTests`): ein gelistetes Objekt wird übertragen, ein nicht gelistetes vor dem ersten Frame abgewiesen; ohne Datei gehen die drei Basisobjekte durch, 1003h und 1018h:05 nicht; eine Datei ohne 1000h verweigert 1000h; eine DCF für eine andere Node-ID wirft und ersetzt die Bindung nicht; die SRDO-Records bis zum höchsten der Datei gehen durch, darüber, jenseits Sub-Index 6/16 und ohne SRDO-Record in der Datei nicht (`CanOpenPeerSafetyTests.The_Gate_Implies_The_Srdo_Records_Up_To_The_Highest_Declared_One`, `Without_An_Srdo_Record_In_The_File_The_Gate_Implies_None`), und ein Gerät mit einer Lücke in den SRDO-Nummern wird konfiguriert, verifiziert und im Boot-up gestartet (`A_Device_Whose_File_Leaves_An_Srdo_Number_Out_Is_Configured_And_Verified`, `CanOpenSafetyBootUpTests.A_Slave_Whose_File_Leaves_An_Srdo_Number_Out_Is_Verified_And_Started`). | Entscheidung 1 (Scope-Dokument 2026-09-26); #174 | | FR-CO-030 | Das System MUSS mit `ObserveForeignPdoAsync` die Nutzlast eines PDO eines anderen Knotens in eine Senke des Aufrufers zerlegen und dabei das eigene OD nicht schreiben. COB-ID (1400h:01 / 1800h:01) und Mapping (1600h–1603h / 1A00h–1A03h) MÜSSEN live über den SDO-Client gelesen werden, also durch das Tor aus FR-CO-029; bricht das Live-Lesen ab, läuft es in die Zeit, verweigert das Tor das Paar, ist der SDO-Kanal des Knotens zu Beginn der Beobachtung belegt oder liefert es kein verwendbares Wort — beim Mapping auch einen Wert anderer Breite als sein Typ: Sub-Index 00h genau ein Byte (UNSIGNED8), ein Eintrag genau vier (UNSIGNED32), nicht auf die niedrigen Bytes gekürzt —, MUSS der Eintrag der an den Aufruf übergebenen EDS/DCF gelten (bei einer DCF der `ParameterValue`). Eine live gelesene COB-ID mit gesetztem Bit 31 wird nicht durch die Datei ersetzt, ein abgebrochener Aufruf fällt nicht auf die Datei zurück; eine unbekannte COB-ID, ein ungültiges PDO und eine eingeschränkte CAN-ID werden nicht zerlegt; eine Nutzlast kürzer als das Mapping schreibt nichts. Die Live-Lesungen einer Beobachtung — COB-IDs, Mapping — sind eine Momentaufnahme des Peers: der Beobachter nimmt den SDO-Kanal des Peers ohne zu warten und hält ihn bis zum Ende, sodass eine währenddessen startende Safety-Transaktion auf ihn wartet (wie auf jeden laufenden Aufruf) und ein anderer SDO-Aufruf dieses Knotens an den Peer als „already in flight“ abgewiesen wird; ein zu Beginn belegter Kanal macht jede Live-Lesung dieser Beobachtung zu „already in flight“, die ganze Momentaufnahme kommt dann aus der Datei — eine live gelesene COB-ID wird nie mit dem Datei-Mapping kombiniert, weil ein Aufrufer dazwischenkam (#295). | Must | Virtual-Loopback-Integrationstest (`CanOpenForeignPdoObserveTests`): Live-Mapping zerlegt ein TPDO ohne OD-Schreibzugriff; SDO-Abbruch, Zeitüberschreitung, Tor-Verweigerung und laufender Transfer nehmen die Datei, ebenso ein Zähler aus zwei Bytes und ein Eintrag aus drei oder fünf Bytes (`A_live_read_that_is_not_a_usable_COB_ID_or_mapping_falls_back_to_the_file`); eine live ungültige COB-ID bleibt ungültig; kurze Nutzlast, unbekannte und eingeschränkte COB-ID liefern kein Signal; ein Aufruf, der zwischen COB-ID- und Mapping-Lesung dazwischenkommt, wird als „already in flight“ abgewiesen und die Momentaufnahme bleibt live (`A_call_cutting_in_mid_snapshot_is_refused_and_the_snapshot_stays_live`). | Entscheidung 2 und Frage 3 (Scope-Dokument 2026-09-26); #163 | | FR-CO-031 | Das System MUSS die Wahl des aktiven NMT-Masters nach CiA 302-2 Version 4.1.0 (NMT flying master, 1F90h) ausführen: Teilnahme über 1F80h Bit 0 und Bit 5 bzw. `StartFlyingMaster`, Anfrage auf 073h, Antwort und Anspruch auf 071h (Priorität, Node-ID), Zeitschlitz-Wettlauf auf 072h mit `Priorität × 1F90h:04 + Node-ID × 1F90h:05`, Neuwahl auf 076h. Eine bessere oder gleiche Priorität setzt diesen Knoten auf Standby, ohne einen aktiven gleichrangigen Master abzusetzen; bei gleicher Priorität gewinnt die kleinere Node-ID; nach der ersten, kalten Wahl ohne Antwort MUSS Reset Communication an alle gehen und warm neu gewählt werden; der Verlust des Heartbeats des aktiven Masters MUSS eine warme Wahl auslösen. Priorität über 2, Device-Zeitschlitz 0 und ein Zeitschlitzpaar, das die Prioritäten nicht trennt (1F90h:04 ≤ 127 × 1F90h:05), MÜSSEN mit 0609 0030h abgewiesen werden. | Must | Virtual-Loopback-Integrationstest mit virtueller Uhr (`CanOpenFlyingMasterTests`, `CanOpenFlyingMasterGapTests`): allein am Bus gewinnt der Knoten nach dem kalten Reset; schlechtere Priorität weicht, bessere erzwingt den Neustart und gewinnt; gleicher Anspruch setzt den aktiven Master nicht ab; gleiche Priorität → kleinere Node-ID; Heartbeat-Verlust startet eine warme Wahl; ungültige 1F90h-Werte → 0609 0030h. | Entscheidung 4 und Frage 4 (Scope-Dokument 2026-09-26); CiA 302-2 v4.1.0 (öffentliche Beschreibung, Mitgliedstext nicht im Repository); #164 | | FR-CO-032 | Als aktiver Master MUSS das System die Slaves aus 1F81h hochfahren: Reset Communication je Slave an dessen Node-ID (nicht bei Bit 4 keep-alive), NMT Start nach seinem ersten Heartbeat oder Boot-up (Bit 2), Pflicht-Slaves (Bit 3) vor dem eigenen Operational und vor einem gemeinsamen Start (1F80h Bit 1); 1F80h Bit 2 hält den eigenen Zustand, Bit 3 unterdrückt das Hochfahren, Bit 4 und Bit 6 wählen die Reaktion auf die Boot-Zeitüberschreitung 1F89h (Reset Node bzw. Stop an alle, sonst Reset Node an den fehlenden Slave). 1F82h MUSS den zuletzt gesehenen Zustand je Node-ID führen; ein Schreibzugriff darauf fordert als aktiver Master das NMT-Kommando an und wird sonst mit 0800 0022h abgewiesen. Ein Tool-Profil beginnt mit gesetzten Bits 2 und 3. | Must | Virtual-Loopback-Integrationstest mit virtueller Uhr (`CanOpenFlyingMasterTests`, `CanOpenFlyingMasterGapTests`): zugewiesener Slave wird zurückgesetzt, dann gestartet und sein Zustand in 1F82h geführt; Bit 3 lässt Slaves unberührt; ein nie antwortender Pflicht-Slave läuft in die Zeit, Bit 4 und Bit 6 wirken; 1F82h-Anforderung nur als aktiver Master; ein Tool startet weder sich noch Slaves, bis die Bits gelöscht sind. | Frage 5 (Scope-Dokument 2026-09-26); CiA 302-2 v4.1.0 (1F80h, 1F81h, 1F82h, 1F89h); #164 | diff --git a/src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs b/src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs index e8e4daa7..c0545d17 100644 --- a/src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs +++ b/src/CanKit.Pro.CANopen/CanOpenDeviceDescription.cs @@ -49,9 +49,12 @@ private CanOpenDeviceDescription(ElectronicDataSheet? eds, DeviceConfigurationFi /// seen from the next call that reads the description. Nothing synchronizes those readers — /// the peer-SDO gate on every SDO call to the peer this description is bound for, the loader, /// the foreign PDO and SRDO observers, a safety transaction's reading at its start — with an - /// edit, so edit only while no such call is running: a structural edit (an object or a - /// sub-object added or removed) during one fails that call with the dictionary's - /// , reported as that call's failure. + /// edit, so edit only while no such call is running. An edit during one is not supported + /// and what that call sees is not specified: the EdsDcfNet dictionaries are not safe for a + /// read concurrent with a write, so a reader that enumerates may fail with the dictionary's + /// , reported as that call's failure, and a reader + /// that looks objects up one at a time around its own awaits may answer from a description + /// that is part old and part new, or find no match, with no error. public EdsDcfNet.Models.ObjectDictionary Objects => Eds?.ObjectDictionary ?? Dcf!.ObjectDictionary; /// The device information section of the description. diff --git a/src/CanKit.Pro.CANopen/README.md b/src/CanKit.Pro.CANopen/README.md index 1e0bcfef..b10b4947 100644 --- a/src/CanKit.Pro.CANopen/README.md +++ b/src/CanKit.Pro.CANopen/README.md @@ -297,12 +297,14 @@ the edit: the gate reads the description on every SDO call to that peer, the loa 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. A structural edit (an object or a sub-object added or removed) during such a call fails -that call: the EdsDcfNet dictionary throws `InvalidOperationException` from its enumeration, and -the call reports it as its own failure — an SDO call fails, a safety verification is not -verified, a boot halts at that slave. A value edit at worst gives that call the old or the new -value. The failure is loud, never a silent wrong reading that passes (#296). An immutable -description — edit, then bind again — is the planned shape for the next major (#299). +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 verification not verified, a +boot halted at that slave); 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 From a83b33cfe316ceaccddd2dcde727576e55914b40 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards <2646931+dborgards@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:49:24 +0200 Subject: [PATCH 3/3] docs(canopen): a failed step-D verification halts the boot only as the boot section says b017d76 gave "a boot halted at that slave" as the consequence of a verification that failed on a concurrent edit. Codex: that is the mandatory slave and the simultaneous start; an optional slave is skipped and the boot goes on (OnSlaveVerified, and the README's own boot-up section). The example now states the slave is not started and points at that section for the rest. Co-Authored-By: Claude Fable 5.1 --- src/CanKit.Pro.CANopen/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.CANopen/README.md b/src/CanKit.Pro.CANopen/README.md index b10b4947..c8f14577 100644 --- a/src/CanKit.Pro.CANopen/README.md +++ b/src/CanKit.Pro.CANopen/README.md @@ -300,8 +300,11 @@ only while no call that reads it is running — in practice, while this node is 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 verification not verified, a -boot halted at that slave); a reader that looks objects up one at a time around its own awaits — +`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](#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).