From 6a3ac6b7dcdcee77180c6f6eb0908dd123b610d1 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards Date: Tue, 27 Oct 2026 08:46:58 +0100 Subject: [PATCH 1/2] feat(canopen): add opt-in guarding error EMCYs --- docs/requirements/SRS-CanKit.Pro.md | 2 +- .../CanOpenNode.CommunicationProfile.cs | 2 + .../CanOpenNode.ErrorRegister.cs | 41 ++- .../CanOpenNode.NodeGuarding.cs | 9 + src/CanKit.Pro.CANopen/CanOpenNode.cs | 9 +- src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs | 12 + src/CanKit.Pro.CANopen/README.md | 17 +- .../CanKit.Pro.CANopen.approved.txt | 1 + .../CANopen/CanOpenGuardingEmcyTests.cs | 285 ++++++++++++++++++ 9 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenGuardingEmcyTests.cs diff --git a/docs/requirements/SRS-CanKit.Pro.md b/docs/requirements/SRS-CanKit.Pro.md index 261ccb8d..0afc77ea 100644 --- a/docs/requirements/SRS-CanKit.Pro.md +++ b/docs/requirements/SRS-CanKit.Pro.md @@ -308,7 +308,7 @@ Ist-Zustand (09.10.2026): ausgeliefert als `CanKit.Pro.CANopen`. Der Zuschnitt d | FR-CO-008 | Das System MUSS Heartbeat-Produktion (periodisches Senden des eigenen Zustands) und Heartbeat-Konsumption (Timeout-Erkennung bei ausbleibendem Heartbeat eines überwachten Node) unterstützen. | Must | Integrationstest: ausbleibender Heartbeat löst innerhalb der konfigurierten Frist ein Timeout-Ereignis aus. | CiA 301 §7.2.8.3.4 | | FR-CO-009 | Das System KANN Node-Guarding (Legacy-Alternative zu Heartbeat) unterstützen. | Could | Integrationstest: Guarding-Request/-Response-Zyklus. | CiA 301 §7.2.8.3.3 | | FR-CO-010 | Das System MUSS SYNC-Objekte senden (als Sync-Producer) und empfangen (als Sync-Consumer) können. | Must | Integrationstest: SYNC-Frame löst konfigurierte SYNC-getriggerte PDOs aus. | CiA 301 §7.2.6 | -| FR-CO-011 | Das System MUSS Emergency-Objekte (EMCY) beim Auftreten interner Fehlerzustände senden und empfangene EMCY-Objekte strukturiert (Error Code, Error Register, Herstellerdaten) an den Applikationsentwickler weiterreichen. Für jedes EMCY, das der Knoten selbst auslöst (8210h RPDO/MPDO-Länge, 8230h MPDO-Ziel, 8240h SYNC-Länge, 8250h RPDO-Timeout, 8210h SRDO-Länge), MUSS er einen Kommunikationsfehler führen: ab dem EMCY, das ihn meldet (einmal je Fehlerereignis), bis seine Ursache endet (passender Frame, SYNC erwarteter Länge, geschriebenes Ziel, Empfang nach dem Timeout, Neukonfiguration von PDO bzw. SRDO). Solange einer aktiv ist, trägt 1001h Bit 4 und Bit 0; die Bits aus `SendEmcyAsync` und ein von der Applikation (oder einer Gerätebeschreibung) über das Objektverzeichnis geschriebener Wert von 1001h gehören der Applikation und werden ODER-verknüpft, nie vom Knoten gelöscht. Endet der letzte, MUSS der Knoten EMCY 0000h mit dem verbleibenden Register senden (Figure 37, Übergang 4 bzw. 2); ein gehaltenes EMCY (Stopped) trägt das Register seiner Sendung; ein NMT-Reset beendet die Kommunikationsfehler ohne EMCY und lässt die Bits der Applikation stehen; bei abgeschaltetem EMCY (1014h Bit 31) wird nichts ausgelöst, ein Ende löscht das Bit trotzdem. | Must | Unit-Test: EMCY-Frame wird korrekt kodiert/dekodiert inkl. aller Felder. Virtual-Loopback-Integrationstest (`CanOpenErrorRegisterTests`, #379): je Quelle Setzen und Rücksetzen, Überlappung mit Applikationsbits, Reset, abgeschaltetes EMCY, gehaltenes EMCY; `SrdoEngineConsumerTests.A_Short_Pair_Raises_Emcy_8210h_Once_And_Is_Not_Processed` für das Ende eines SRDO-Laufs. | CiA 301 §7.2.7.1 (Figure 37), §7.5.2.2 Table 54 | +| FR-CO-011 | Das System MUSS Emergency-Objekte (EMCY) beim Auftreten interner Fehlerzustände senden und empfangene EMCY-Objekte strukturiert (Error Code, Error Register, Herstellerdaten) an den Applikationsentwickler weiterreichen. Für jedes EMCY, das der Knoten selbst auslöst (8210h RPDO/MPDO-Länge, 8230h MPDO-Ziel, 8240h SYNC-Länge, 8250h RPDO-Timeout, 8210h SRDO-Länge), MUSS er einen Kommunikationsfehler führen: ab dem EMCY, das ihn meldet (einmal je Fehlerereignis), bis seine Ursache endet (passender Frame, SYNC erwarteter Länge, geschriebenes Ziel, Empfang nach dem Timeout, Neukonfiguration von PDO bzw. SRDO). Mit `CanOpenNodeOptions.EmitGuardingErrorEmcy` (Standard `false`) meldet der Knoten optional auch Heartbeat-Consumer- und lokale Life-Guarding-Timeouts als 8130h; wiederholte Timeouts derselben Ursache sind kein neues Fehlerereignis. Empfang bzw. Entfernen/Ändern des Heartbeat-Watches und Life-Guarding-Recovery/-Deaktivierung beenden diese Ursachen. Solange einer aktiv ist, trägt 1001h Bit 4 und Bit 0; die Bits aus `SendEmcyAsync` und ein von der Applikation (oder einer Gerätebeschreibung) über das Objektverzeichnis geschriebener Wert von 1001h gehören der Applikation und werden ODER-verknüpft, nie vom Knoten gelöscht. Endet der letzte, MUSS der Knoten EMCY 0000h mit dem verbleibenden Register senden (Figure 37, Übergang 4 bzw. 2); ein gehaltenes EMCY (Stopped) trägt das Register seiner Sendung; ein NMT-Reset beendet die Kommunikationsfehler ohne EMCY und lässt die Bits der Applikation stehen; bei abgeschaltetem EMCY (1014h Bit 31) wird nichts ausgelöst, ein Ende löscht das Bit trotzdem. | Must | Unit-Test: EMCY-Frame wird korrekt kodiert/dekodiert inkl. aller Felder. Virtual-Loopback-Integrationstest (`CanOpenErrorRegisterTests`, #379): je Quelle Setzen und Rücksetzen, Überlappung mit Applikationsbits, Reset, abgeschaltetes EMCY, gehaltenes EMCY; `CanOpenGuardingEmcyTests` (#423) prüft Opt-in, mehrere Producer, Recovery, Rekonfiguration, Reset und Producer-Deaktivierung; `SrdoEngineConsumerTests.A_Short_Pair_Raises_Emcy_8210h_Once_And_Is_Not_Processed` für das Ende eines SRDO-Laufs. | CiA 301 §7.2.7.1 (Figure 37), §7.5.2.2 Table 54 | | FR-CO-012 | Das System SOLLTE das L2-Multi-Consumer-Demultiplexing (FR-RAW-010ff.) nutzen, um NMT-, SDO-, PDO- und SYNC-Verkehr eines Node parallel und unabhängig zu verarbeiten. | Should | Integrationstest: gleichzeitiger SDO- und PDO-Verkehr auf demselben Node ohne gegenseitige Verzögerung. | Abgeleitet aus L2-Anforderungen | | FR-CO-013 | Das System MUSS beim Öffnen eines Knotens die Kommunikationsprofil-Objekte 1000h, 1001h, 1018h (sub0 = 01h, Vendor-ID 0 = „keine Vendor-ID zugeteilt“), 1005h, 1006h, 100Ch, 100Dh, 1010h, 1011h, 1014h, 1016h, 1017h, 1200h sowie die PDO-Records 1400h–1403h, 1600h–1603h, 1800h–1803h und 1A00h–1A03h mit ihren CiA-301-Vorgabewerten im OD anlegen (PDOs „nicht gültig“, Bit 31 gesetzt). Ohne die Option `WritableCommunicationParameters` MÜSSEN die PDO-Records auf dem Bus `ro` sein (Download → 0601 0002h); 1200h ist konstant; der reservierte Sub-Index 04h der PDO-Kommunikationsrecords MUSS fehlen, so dass ein Zugriff 0609 0011h ergibt. | Must | Virtual-Loopback-Integrationstest (`CanOpenCommunicationProfileTests`): SDO-Upload jedes Objekts liefert den Vorgabewert; Download auf 1800h:02 ohne Option → 0601 0002h, mit Option angenommen; Zugriff auf 1800h:04 → 0609 0011h. | CiA 301 §7.5.2.1, §7.5.2.2, §7.5.2.21 und Objektübersicht `ro M` (Posten 3); Fußnote `*` der Objektübersicht (Posten 4, 16); §7.5.2.5, §7.5.2.6, §7.5.2.17 (Posten 19); §7.5.2.11, §7.5.2.12 (Posten 24); §7.5.2.37 zu Sub-Index 04h (Posten 18); Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 4b, 21) für 1200h und 1016h/1017h | | FR-CO-014 | Das System MUSS das OD als einzige Quelle der Kommunikationskonfiguration führen, in beiden Richtungen und auf beiden Schreibpfaden: ein Wert, den das OD für ein vom Knoten verwaltetes Objekt annimmt — per SDO-Download wie per lokalem Schreibzugriff über `ObjectDictionary` —, MUSS im zugehörigen Dienst (SYNC, EMCY, Heartbeat, Life Guarding, PDO) wirken; `StartHeartbeatProducer`, `AddHeartbeatConsumer`, `StartSyncProducer`, `SendEmcyAsync` (1001h), `ConfigureTpdo` und `ConfigureRpdo` MÜSSEN als Schreibzugriffe auf diese Objekte ausgeführt werden; ein nicht umsetzbarer Wert MUSS vor dem Speichern abgewiesen werden (SDO: Abort-Code, lokal: `ArgumentException` mit demselben Code im Text); ein verwaltetes Objekt darf per `Add*` nicht neu deklariert werden (`InvalidOperationException`). | Must | Virtual-Loopback-Integrationstest (`CanOpenCommunicationProfileTests`): 1017h per SDO gesetzt startet den Heartbeat; `StartSyncProducer` schreibt 1006h und Bit 30 von 1005h; `SendEmcyAsync` spiegelt 1001h; `AddU32(0x1006, …)` wirft; lokaler Schreibzugriff mit ungültigem Wert wirft mit dem Abort-Code im Text. | Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 14, 20, 22, 23) | diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.CommunicationProfile.cs b/src/CanKit.Pro.CANopen/CanOpenNode.CommunicationProfile.cs index 2207bdb7..b00e929e 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNode.CommunicationProfile.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNode.CommunicationProfile.cs @@ -732,6 +732,7 @@ private void ApplyEmcyConfiguration() uint word = _od.ReadUnsigned(Co.EmcyCobId, 0x00); _emcyCobId = word & CanOpenCobId.CanIdMask; _emcyValid = (word & CanOpenCobId.InvalidBit) == 0; + ReportActiveGuardingErrors(); } // §7.5.2.20: "The value 0 shall disable the producer heartbeat." The producer module follows @@ -761,6 +762,7 @@ private void RebuildHeartbeatConsumers() desired[nodeId] = TimeSpan.FromMilliseconds(ms); } + ReconcileHeartbeatEmcyWatches(desired); _heartbeatConsumer.Replace(desired); } diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs b/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs index f3967db6..e0236205 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs @@ -10,8 +10,9 @@ namespace CanKit.Pro.CANopen; // FR-CO-011, #379: the error register (1001h) and the EMCY error state of CiA 301 §7.2.7.1. // // One rule for every EMCY the node raises itself — RPDO and MPDO length 8210h, MPDO destination -// 8230h, SYNC length 8240h, RPDO timeout 8250h, SRDO length 8210h. Each is a communication error -// (Table 26, 82xxh "Protocol error" under 80xxh "Monitoring"), so while one is active the register +// 8230h, SYNC length 8240h, RPDO timeout 8250h, SRDO length 8210h, and opt-in heartbeat/life- +// guarding timeout 8130h. Each is a communication error +// (Table 26, 81xxh/82xxh under 80xxh "Monitoring"), so while one is active the register // carries bit 4, "Communication error", and bit 0, because "The generic error shall be signaled at // any error situation" (§7.5.2.2, Table 54). The error is active from the EMCY that reports it — // "transmitted only once per 'error event'" — until its cause ends: a frame that fits, a SYNC of @@ -40,6 +41,8 @@ private enum CommunicationError RpdoTimeout = 3, SyncLength = 4, SrdoLength = 5, + HeartbeatTimeout = 6, + LifeGuardingTimeout = 7, } // The application's part of 1001h: what its last transmitted SendEmcyAsync passed, or what it @@ -59,6 +62,39 @@ private enum CommunicationError // The communication errors the node reported and whose cause has not ended. Actor loop only. private readonly HashSet _communicationErrors = new(); + // Last timeout of each still-silent watched producer; retained while EMCY is disabled so + // enabling the producer can report an error that is still active. Actor loop only. + private readonly Dictionary _heartbeatEmcyTimeouts = new(); + + private void NoteHeartbeatForMonitoring(byte producer) + { + _heartbeatConsumer.NoteReceived(producer); + if (!_options.EmitGuardingErrorEmcy) return; + _heartbeatEmcyTimeouts.Remove(producer); + EndCommunicationError(CommunicationError.HeartbeatTimeout, producer); + } + + private void ReconcileHeartbeatEmcyWatches(IReadOnlyDictionary desired) + { + if (!_options.EmitGuardingErrorEmcy) return; + foreach (var previous in new List>(_heartbeatEmcyTimeouts)) + { + if (desired.TryGetValue(previous.Key, out var timeout) && timeout == previous.Value) continue; + _heartbeatEmcyTimeouts.Remove(previous.Key); + EndCommunicationError(CommunicationError.HeartbeatTimeout, previous.Key); + } + } + + private void ReportActiveGuardingErrors() + { + if (!_options.EmitGuardingErrorEmcy || !_emcyValid) return; + foreach (var producer in _heartbeatEmcyTimeouts.Keys) + RaiseCommunicationError(CommunicationError.HeartbeatTimeout, producer, 0x8130); + if (_lifeGuardingOccurred) + RaiseCommunicationError(CommunicationError.LifeGuardingTimeout, 0, 0x8130); + } + + // Whether an EMCY was ever queued: from then on, a reset writes the register it leaves // behind, in its place on the chain. Before, 1001h holds what the node created it with, or // what the application wrote to it. Actor loop only. @@ -147,6 +183,7 @@ private void EmitPendingEmcy(EmcyMessage pending) private void EndCommunicationErrorsForReset() { _communicationErrors.Clear(); + _heartbeatEmcyTimeouts.Clear(); _heldNodeEmcy = false; if (_errorRegisterQueued) QueueErrorRegisterWrite(nodeBits: 0); } diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.NodeGuarding.cs b/src/CanKit.Pro.CANopen/CanOpenNode.NodeGuarding.cs index f0b9c841..bbf83a81 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNode.NodeGuarding.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNode.NodeGuarding.cs @@ -328,10 +328,19 @@ private void ResetLifeGuardingState() _lifeGuardingDeadline?.Dispose(); _lifeGuardingDeadline = null; _lifeGuardingOccurred = false; + if (_options.EmitGuardingErrorEmcy) + EndCommunicationError(CommunicationError.LifeGuardingTimeout); } private void RaiseLifeGuardingEvent(LifeGuardingState state) { + if (_options.EmitGuardingErrorEmcy) + { + if (state == LifeGuardingState.Occurred) + RaiseCommunicationError(CommunicationError.LifeGuardingTimeout, 0, 0x8130); + else + EndCommunicationError(CommunicationError.LifeGuardingTimeout); + } var args = new LifeGuardingEventArgs(state, _guardTime, _lifeTimeFactor); EnqueueEvent(() => { diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.cs b/src/CanKit.Pro.CANopen/CanOpenNode.cs index 03f2ae7a..48ea708a 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNode.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNode.cs @@ -1567,7 +1567,7 @@ private void HandleIncomingFrame(uint cobId, byte[] data, bool isRtr, bool isFd) { byte guardedState = (byte)(data[0] & 0x7F); NoteSlaveNmtState(producer, guardedState); - _heartbeatConsumer.NoteReceived(producer); + NoteHeartbeatForMonitoring(producer); } return; } @@ -1778,7 +1778,7 @@ private void HandleHeartbeat(uint cobId, byte[] data) if ((data[0] & 0x80) == 0 && TryDecodeHeartbeatState(stateByte, out var state)) RaiseHeartbeatReceived(producer, state, DateTime.UtcNow); NoteSlaveNmtState(producer, stateByte); - _heartbeatConsumer.NoteReceived(producer); + NoteHeartbeatForMonitoring(producer); } // ========================================================================================= @@ -3043,6 +3043,11 @@ private void RaiseHeartbeatReceived(byte producer, NmtState state, DateTime ts) private void RaiseHeartbeatTimeout(byte producer, TimeSpan timeout) { + if (_options.EmitGuardingErrorEmcy) + { + _heartbeatEmcyTimeouts[producer] = timeout; + RaiseCommunicationError(CommunicationError.HeartbeatTimeout, producer, 0x8130); + } var args = new HeartbeatTimeoutEventArgs(producer, timeout); EnqueueEvent(() => { diff --git a/src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs b/src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs index 6cddb45a..1946975e 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs @@ -16,6 +16,17 @@ namespace CanKit.Pro.CANopen; /// public sealed class CanOpenNodeOptions { + /// + /// Emit optional 8130h EMCYs for heartbeat-consumer timeouts and this node's life- + /// guarding timeout. Defaults to ; timeout events remain available + /// regardless. Enabled errors contribute bits 4 and 0 to 1001h until reception, + /// removal/reconfiguration of the watch, a guarding poll or disabling life guarding ends + /// their cause. The last repaired communication error emits 0000h. + /// + /// Does not enable controller-state or node-guarding-consumer EMCYs. Existing + /// With copy methods preserve this setting. + public bool EmitGuardingErrorEmcy { get; init; } + /// Client-side SDO transfer timeout, applied to every request (initiate as well as /// each segment ack). In a block download it also restarts at every confirmed segment of a /// sub-block, so it measures the server's silence and not the time a sub-block takes to send. @@ -318,6 +329,7 @@ private CanOpenNodeOptions Copy( { var options = new CanOpenNodeOptions { + EmitGuardingErrorEmcy = EmitGuardingErrorEmcy, PdoCount = pdoCount ?? PdoCount, SdoTimeout = sdoTimeout ?? SdoTimeout, SdoServerTimeout = sdoServerTimeout ?? SdoServerTimeout, diff --git a/src/CanKit.Pro.CANopen/README.md b/src/CanKit.Pro.CANopen/README.md index ae07012a..a1620283 100644 --- a/src/CanKit.Pro.CANopen/README.md +++ b/src/CanKit.Pro.CANopen/README.md @@ -639,7 +639,7 @@ may be written in any order, so a slot not yet overwritten does not count agains ## Error control **Error register (`1001h`) and error reset.** Every EMCY the node raises itself is a -communication error (CiA 301 Table 26, `82xxh`) and follows one rule (§7.2.7.1 and Figure 37; +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 @@ -660,8 +660,19 @@ errors without an EMCY (transitions 5 and 0) and leave the application's bits in 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). Heartbeat, node-guarding and life-guarding timeouts and the CAN -controller state raise no EMCY (`8110h`–`8140h`); they are events only. +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 and CAN controller states +still raise events only; the remaining optional `8110h`/`8120h`/`8140h` feature work stays in #423. **Heartbeat.** The producer and the consumer are separate modules. `HeartbeatProducer` sends this node's heartbeat; `HeartbeatConsumer` watches other nodes. Neither module references the diff --git a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.CANopen.approved.txt b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.CANopen.approved.txt index f6de3916..90de733d 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.CANopen.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.CANopen.approved.txt @@ -93,6 +93,7 @@ namespace CanKit.Pro.CANopen { public CanOpenNodeOptions() { } public System.TimeSpan DefaultTpdoEventTimerInterval { get; init; } + public bool EmitGuardingErrorEmcy { get; init; } public bool EnableChangeOfStateSrdo { get; init; } public bool EnableChangeOfStateTpdo { get; init; } public int EventQueueCapacity { get; init; } diff --git a/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenGuardingEmcyTests.cs b/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenGuardingEmcyTests.cs new file mode 100644 index 00000000..0c336857 --- /dev/null +++ b/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenGuardingEmcyTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Threading.Tasks; +using AwesomeAssertions; +using CanKit.Pro.CANopen; +using CanKit.Pro.CANopen.Nmt; +using CanKit.Pro.Tests.Infrastructure; +using Xunit; + +namespace CanKit.Pro.Tests.TestCases.CANopen; + +// #423 / FR-CO-011: optional 8130h uses the same aggregate error register and EMCY chain as +// the node's PDO errors. Time advances only after actor barriers; an application EMCY is the +// ordering witness for every no-duplicate assertion. +public partial class CanOpenPdoEngineTests +{ + private static readonly CanOpenNodeOptions GuardingEmcyOptions = new() + { + EmitGuardingErrorEmcy = true, + RespondToNodeGuardingRtr = true, + }; + + private static void ConfigureLifeGuarding(ICanOpenNode node) + { + node.ObjectDictionary.WriteUnsigned(0x100C, 0, 50); + node.ObjectDictionary.WriteUnsigned(0x100D, 0, 2); + Settle(node); + } + + private static async Task PollLifeGuardingAsync(Wire wire, ICanOpenNode node) + { + var id = CanOpenCobId.Heartbeat(Device); + int expected = wire.Count(id) + 1; + wire.SendRtr(id); + await wire.WaitForCountAsync(id, expected); + Settle(node); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Guarding_Emcy_Is_Opt_In_And_Timeout_Events_Are_Unchanged(bool lifeGuarding) + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock); + await StartAsync(wire, node); + await wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 1); + var timedOut = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + node.HeartbeatTimeout += (_, _) => timedOut.TrySetResult(true); + node.LifeGuardingEvent += (_, e) => + { + if (e.State == LifeGuardingState.Occurred) timedOut.TrySetResult(true); + }; + if (lifeGuarding) + { + ConfigureLifeGuarding(node); + await PollLifeGuardingAsync(wire, node); + } + else node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + await timedOut.Task.WaitAsync(ShortTimeout); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, 1, 0x1000, 0); + wire.Count(EmcyId).Should().Be(1); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Heartbeat_Emcy_Is_Once_Per_Lapse_And_Preserves_Application_Bits_On_Recovery(bool guardedReply) + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock, GuardingEmcyOptions); + await StartAsync(wire, node); + node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + if (guardedReply) node.StartNodeGuardingConsumer(1, TimeSpan.FromSeconds(10), 1); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + await ExpectEmcyAsync(wire, 1, 0x8130, Communication); + Advance(clock, node, TimeSpan.FromMilliseconds(100)); // repeated timeout, same error event + await node.SendEmcyAsync(0x1000, 0x04); + await ExpectEmcyAsync(wire, 2, 0x1000, 0x15); + await Inject(node, CanOpenCobId.Heartbeat(1), (byte)NmtState.PreOperational); + await ExpectEmcyAsync(wire, 3, ErrorReset, 0x04); + node.ObjectDictionary.ReadUnsigned(0x1001, 0).Should().Be(0x04); + wire.Count(EmcyId).Should().Be(3); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Heartbeat_Emcy_Only_Resets_When_The_Last_Producer_Recovers(bool removeWatch) + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock, GuardingEmcyOptions); + await StartAsync(wire, node); + node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + node.AddHeartbeatConsumer(2, TimeSpan.FromMilliseconds(100)); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + await ExpectEmcyAsync(wire, 1, 0x8130, Communication); + await ExpectEmcyAsync(wire, 2, 0x8130, Communication); + if (removeWatch) node.RemoveHeartbeatConsumer(1); + else await Inject(node, CanOpenCobId.Heartbeat(1), (byte)NmtState.PreOperational); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, 3, 0x1000, Communication); + await Inject(node, CanOpenCobId.Heartbeat(2), (byte)NmtState.PreOperational); + await ExpectEmcyAsync(wire, 4, ErrorReset, 0); + wire.Count(EmcyId).Should().Be(4); + } + + [Fact] + public async Task Heartbeat_Emcy_Reconfiguration_Restarts_The_Watch_But_An_Unchanged_Write_Does_Not() + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock, GuardingEmcyOptions); + await StartAsync(wire, node); + node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + await ExpectEmcyAsync(wire, 1, 0x8130, Communication); + node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, 2, 0x1000, Communication); + node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(200)); + Settle(node); + await ExpectEmcyAsync(wire, 3, ErrorReset, 0); + Advance(clock, node, TimeSpan.FromMilliseconds(201)); + await ExpectEmcyAsync(wire, 4, 0x8130, Communication); + node.RemoveHeartbeatConsumer(1); + Settle(node); + await ExpectEmcyAsync(wire, 5, ErrorReset, 0); + Advance(clock, node, TimeSpan.FromSeconds(1)); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, 6, 0x1000, 0); + wire.Count(EmcyId).Should().Be(6); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Guarding_Emcy_Reports_Only_Still_Active_Faults_When_Reenabled(bool lifeGuarding, bool recoverWhileDisabled) + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock, GuardingEmcyOptions); + await StartAsync(wire, node); + await wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 1); + node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId | CanOpenCobId.InvalidBit); + if (lifeGuarding) + { + ConfigureLifeGuarding(node); + await PollLifeGuardingAsync(wire, node); + } + else node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + if (recoverWhileDisabled) + { + if (lifeGuarding) await PollLifeGuardingAsync(wire, node); + else await Inject(node, CanOpenCobId.Heartbeat(1), (byte)NmtState.PreOperational); + } + node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId); + Settle(node); + if (!recoverWhileDisabled) await ExpectEmcyAsync(wire, 1, 0x8130, Communication); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, recoverWhileDisabled ? 1 : 2, 0x1000, + recoverWhileDisabled ? (byte)0 : Communication); + if (!recoverWhileDisabled) + { + node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId | CanOpenCobId.InvalidBit); + node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId); + Settle(node); + if (lifeGuarding) await PollLifeGuardingAsync(wire, node); + else await Inject(node, CanOpenCobId.Heartbeat(1), (byte)NmtState.PreOperational); + await ExpectEmcyAsync(wire, 3, ErrorReset, 0); // enabling again did not duplicate the lapse + } + wire.Count(EmcyId).Should().Be(recoverWhileDisabled ? 1 : 3); + } + + [Theory] + [InlineData("poll")] + [InlineData("guard-time")] + [InlineData("factor")] + [InlineData("heartbeat-mode")] + public async Task Life_Guarding_Emcy_Ends_On_A_Poll_Or_When_Guarding_Is_Disabled(string recovery) + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock, GuardingEmcyOptions); + await StartAsync(wire, node); + await wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 1); + ConfigureLifeGuarding(node); + await PollLifeGuardingAsync(wire, node); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + await ExpectEmcyAsync(wire, 1, 0x8130, Communication); + Advance(clock, node, TimeSpan.FromMilliseconds(100)); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, 2, 0x1000, Communication); + switch (recovery) + { + case "poll": await PollLifeGuardingAsync(wire, node); break; + case "guard-time": node.ObjectDictionary.WriteUnsigned(0x100C, 0, 0); break; + case "factor": node.ObjectDictionary.WriteUnsigned(0x100D, 0, 0); break; + default: node.StartHeartbeatProducer(TimeSpan.FromMilliseconds(20)); break; + } + Settle(node); + await ExpectEmcyAsync(wire, 3, ErrorReset, 0); + wire.Count(EmcyId).Should().Be(3); + } + + [Fact] + public async Task Life_Guarding_Emcy_Is_Reported_After_Enable_And_A_Reset_Does_Not_Emit_An_Error_Reset() + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock, GuardingEmcyOptions); + await StartAsync(wire, node); + await wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 1); + node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId | CanOpenCobId.InvalidBit); + ConfigureLifeGuarding(node); + await PollLifeGuardingAsync(wire, node); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId); + Settle(node); + await ExpectEmcyAsync(wire, 1, 0x8130, Communication); + wire.SendNmt(NmtCommand.ResetCommunication, Device); + await wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 3); // boot-up, poll reply, reset boot-up + Settle(node); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, 2, 0x1000, 0); + wire.Count(EmcyId).Should().Be(2); + } + + [Fact] + public async Task Heartbeat_Emcy_Reset_Forgets_Lapses_But_Restores_The_Configured_Watch() + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock, GuardingEmcyOptions); + await StartAsync(wire, node); + await wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 1); + node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + node.StoreParameters(); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + await ExpectEmcyAsync(wire, 1, 0x8130, Communication); + wire.SendNmt(NmtCommand.ResetCommunication, Device); + await wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 2); + Settle(node); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, 2, 0x1000, 0); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + await ExpectEmcyAsync(wire, 3, 0x8130, Communication); + node.RemoveHeartbeatConsumer(1); + Settle(node); + await ExpectEmcyAsync(wire, 4, ErrorReset, 0); + wire.Count(EmcyId).Should().Be(4); + } + + [Fact] + public void Guarding_Emcy_Option_Is_Preserved_By_Existing_Copy_Methods() + { + new CanOpenNodeOptions().EmitGuardingErrorEmcy.Should().BeFalse(); + GuardingEmcyOptions.With(sdoTimeout: TimeSpan.FromSeconds(2)).EmitGuardingErrorEmcy.Should().BeTrue(); + GuardingEmcyOptions.WithLss(null).EmitGuardingErrorEmcy.Should().BeTrue(); + GuardingEmcyOptions.WithSrdoConfigurationCheckInterval(null).EmitGuardingErrorEmcy.Should().BeTrue(); + } +} From 5f763596d3bc3613d0e006b8a0b57e580ca19452 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards Date: Tue, 27 Oct 2026 09:05:21 +0100 Subject: [PATCH 2/2] fix(canopen): filter changed heartbeat watches explicitly --- .../CanOpenNode.ErrorRegister.cs | 9 ++++++-- .../CANopen/CanOpenGuardingEmcyTests.cs | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs b/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs index e0236205..79b528bd 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CanKit.Pro.CANopen.Emcy; @@ -77,12 +78,16 @@ private void NoteHeartbeatForMonitoring(byte producer) private void ReconcileHeartbeatEmcyWatches(IReadOnlyDictionary desired) { if (!_options.EmitGuardingErrorEmcy) return; - foreach (var previous in new List>(_heartbeatEmcyTimeouts)) + foreach (var previous in _heartbeatEmcyTimeouts + .Where(WatchChanged) + .ToList()) { - if (desired.TryGetValue(previous.Key, out var timeout) && timeout == previous.Value) continue; _heartbeatEmcyTimeouts.Remove(previous.Key); EndCommunicationError(CommunicationError.HeartbeatTimeout, previous.Key); } + + bool WatchChanged(KeyValuePair previous) + => !desired.TryGetValue(previous.Key, out var timeout) || timeout != previous.Value; } private void ReportActiveGuardingErrors() diff --git a/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenGuardingEmcyTests.cs b/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenGuardingEmcyTests.cs index 0c336857..a1d97676 100644 --- a/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenGuardingEmcyTests.cs +++ b/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenGuardingEmcyTests.cs @@ -115,6 +115,27 @@ public async Task Heartbeat_Emcy_Only_Resets_When_The_Last_Producer_Recovers(boo wire.Count(EmcyId).Should().Be(4); } + [Fact] + public async Task Heartbeat_Emcy_Rebuilding_Other_Watches_Preserves_An_Unchanged_Active_Lapse() + { + var session = NewSession(); + using var bus = Open(session, 0); + using var wire = new Wire(session, 2); + var clock = new ManualTimeSource(); + using var node = OpenClocked(bus, Device, clock, GuardingEmcyOptions); + await StartAsync(wire, node); + node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + Advance(clock, node, TimeSpan.FromMilliseconds(101)); + await ExpectEmcyAsync(wire, 1, 0x8130, Communication); + node.AddHeartbeatConsumer(2, TimeSpan.FromMilliseconds(200)); + Settle(node); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(wire, 2, 0x1000, Communication); + await Inject(node, CanOpenCobId.Heartbeat(1), (byte)NmtState.PreOperational); + await ExpectEmcyAsync(wire, 3, ErrorReset, 0); + wire.Count(EmcyId).Should().Be(3); + } + [Fact] public async Task Heartbeat_Emcy_Reconfiguration_Restarts_The_Watch_But_An_Unchanged_Write_Does_Not() {