diff --git a/docs/requirements/SRS-CanKit.Pro.md b/docs/requirements/SRS-CanKit.Pro.md index 0afc77ea..01895621 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). 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-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. Mit `CanOpenNodeOptions.EmitControllerErrorEmcy` (Standard `false`) wird ein beobachteter error-passive Controller optional als 8120h geführt; error-active, error-warning oder kein Fehler reparieren diese Ursache, BusOff bzw. unbekannter Zustand gelten nicht als Reparaturnachweis. Ein beim Start passiver Controller wird bei Eintritt in Pre-Operational gemeldet; Reset liest den physischen Zustand und die Vergleichsbaseline frisch, statt einen zwischengespeicherten Fehler zu wiederholen; Reset invalidiert ältere eingereihte Baselines und Retries auch bei Rückkehr zur unkonfigurierten LSS-ID; die Statusabfrage läuft als eigener Actor-Auftrag, sodass ihr Fehler weder Reset-Echo-Verwaltung noch LSS-Antwort unterbricht; erwartbare Status-/Adapterfehler werden gemeldet und auf der Actor-Uhr erneut versucht. 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; `CanOpenControllerEmcyTests` prüft initialen Controllerzustand, Poll-/Hint-Erkennung, Recovery, mehrere Ursachen, Reset und Monitor-Lifetime; `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 b00e929e..8c38c0d5 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNode.CommunicationProfile.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNode.CommunicationProfile.cs @@ -733,6 +733,7 @@ private void ApplyEmcyConfiguration() _emcyCobId = word & CanOpenCobId.CanIdMask; _emcyValid = (word & CanOpenCobId.InvalidBit) == 0; ReportActiveGuardingErrors(); + ReportActiveControllerError(); } // §7.5.2.20: "The value 0 shall disable the producer heartbeat." The producer module follows @@ -950,6 +951,7 @@ private void EnterPreOperationalFromReset() _heartbeatProducer.RestartCycle(); _ = EmitHeartbeat(0x00); if (_heartbeatProducer.Interval > TimeSpan.Zero) _ = EmitHeartbeat((byte)NmtState.PreOperational); + StartControllerErrorMonitoring(); } private void AbortServerSessions(SdoAbortCode code) diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.ControllerEmcy.cs b/src/CanKit.Pro.CANopen/CanOpenNode.ControllerEmcy.cs new file mode 100644 index 00000000..b87b0ccb --- /dev/null +++ b/src/CanKit.Pro.CANopen/CanOpenNode.ControllerEmcy.cs @@ -0,0 +1,110 @@ +using System; +using CanKit.Abstractions.API.Common.Definitions; +using CanKit.Core.Exceptions; +using CanKit.Pro.Reliability; + +namespace CanKit.Pro.CANopen; + +// #423 / FR-CO-011: optional 8120h is an observed controller-level cause. Bus-off and unknown +// readings do not prove that a previously observed passive condition has been repaired. +internal sealed partial class CanOpenNode +{ + private BusStateMonitor? _controllerEmcyMonitor; + private bool _controllerErrorPassive; + private bool _controllerEmcyStarted; + private IDisposable? _controllerBaselineRetry; + private long _controllerBaselineGeneration; + + private void InitializeControllerErrorMonitor() + { + if (!_options.EmitControllerErrorEmcy) return; + _controllerEmcyMonitor = new BusStateMonitor(_service.Bus, _actor); + _controllerEmcyMonitor.StateChanged += OnControllerEmcyStateChanged; + } + + private void InvalidateControllerErrorBaseline() + { + _controllerEmcyStarted = false; + _controllerBaselineGeneration++; + _controllerBaselineRetry?.Dispose(); + _controllerBaselineRetry = null; + } + + private void StartControllerErrorMonitoring() + { + if (_controllerEmcyMonitor is null) return; + InvalidateControllerErrorBaseline(); + long generation = _controllerBaselineGeneration; + // Diagnostic reads are a separate actor job. Even an unexpected adapter exception must + // not interrupt the reset caller's echo bookkeeping or an LSS configure-node-id reply. + _actor.Post(() => RefreshControllerErrorBaseline(generation)); + } + + private void RefreshControllerErrorBaseline(long generation) + { + if (_controllerEmcyMonitor is null) return; + if (generation != _controllerBaselineGeneration) return; + _controllerBaselineRetry?.Dispose(); + _controllerBaselineRetry = null; + _controllerEmcyStarted = false; + + // A reset starts a new error history. Refresh both the physical sample and the monitor's + // comparison baseline: reusing CurrentState could replay a repaired pre-reset lapse, and + // sampling only the bus could hide the next passive edge behind the monitor's old cache. + BusStateMonitor fresh; + try + { + fresh = new BusStateMonitor(_service.Bus, _actor); + } + catch (Exception ex) when (ex is CanKitException or ObjectDisposedException) + { + // A transient status-read failure must neither invent a fresh error nor remove the + // existing monitor. Retry on the same actor clock; normal teardown cancels this work. + RaiseBackgroundException(ex); + _controllerBaselineRetry = _actor.Schedule(System.TimeSpan.FromMilliseconds(50), () => RefreshControllerErrorBaseline(generation)); + return; + } + fresh.StateChanged += OnControllerEmcyStateChanged; + DisposeControllerErrorMonitor(); + _controllerEmcyMonitor = fresh; + _controllerErrorPassive = false; + _controllerEmcyStarted = true; + ObserveControllerEmcyState(fresh.CurrentState); + } + + private void OnControllerEmcyStateChanged(object? sender, BusStateChangedEventArgs e) + => ObserveControllerEmcyState(e.Current); + + private void ObserveControllerEmcyState(BusState state) + { + switch (state) + { + case BusState.ErrPassive: + _controllerErrorPassive = true; + break; + case BusState.None: + case BusState.ErrActive: + case BusState.ErrWarning: + _controllerErrorPassive = false; + EndCommunicationError(CommunicationError.ControllerPassive); + return; + } + ReportActiveControllerError(); + } + + private void ReportActiveControllerError() + { + if (_controllerEmcyStarted && _controllerErrorPassive) + RaiseCommunicationError(CommunicationError.ControllerPassive, 0, 0x8120); + } + + private void DisposeControllerErrorMonitor() + { + _controllerBaselineRetry?.Dispose(); + _controllerBaselineRetry = null; + if (_controllerEmcyMonitor is not { } monitor) return; + monitor.StateChanged -= OnControllerEmcyStateChanged; + monitor.Dispose(); + _controllerEmcyMonitor = null; + } +} diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs b/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs index 79b528bd..5949c0bb 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs @@ -12,7 +12,7 @@ namespace CanKit.Pro.CANopen; // // 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, and opt-in heartbeat/life- -// guarding timeout 8130h. Each is a communication error +// guarding timeout 8130h or controller error-passive 8120h. 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 — @@ -44,6 +44,7 @@ private enum CommunicationError SrdoLength = 5, HeartbeatTimeout = 6, LifeGuardingTimeout = 7, + ControllerPassive = 8, } // The application's part of 1001h: what its last transmitted SendEmcyAsync passed, or what it @@ -189,6 +190,7 @@ private void EndCommunicationErrorsForReset() { _communicationErrors.Clear(); _heartbeatEmcyTimeouts.Clear(); + InvalidateControllerErrorBaseline(); _heldNodeEmcy = false; if (_errorRegisterQueued) QueueErrorRegisterWrite(nodeBits: 0); } diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.cs b/src/CanKit.Pro.CANopen/CanOpenNode.cs index 48ea708a..dd6dbc34 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNode.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNode.cs @@ -256,9 +256,11 @@ internal CanOpenNode(ICanBusService service, byte nodeId, CanOpenNodeOptions opt // A device description shapes the dictionary on top of that, before the node is on // the bus (CanOpenNode.DeviceDescription.cs). if (description is not null) ApplyDeviceDescription(description); + InitializeControllerErrorMonitor(); } catch { + DisposeControllerErrorMonitor(); _actor.Dispose(); throw; } @@ -323,6 +325,7 @@ CanOpenCobId.FlyingMasterClaim or CanOpenCobId.FlyingMasterTrigger catch { CompleteEventQueue(); + DisposeControllerErrorMonitor(); _actor.Dispose(); throw; } @@ -344,6 +347,7 @@ CanOpenCobId.FlyingMasterClaim or CanOpenCobId.FlyingMasterTrigger if (LayerSettingsBlockBoot()) return; _state = NmtState.PreOperational; _ = EmitHeartbeat(0x00); + StartControllerErrorMonitoring(); }); _readerTask = Task.Run(RunReaderAsync); @@ -953,6 +957,7 @@ private bool BeginDispose() /// Stops the producers and consumers and fails every open transfer. Actor-only. private void CleanUpOnActor() { + DisposeControllerErrorMonitor(); _heartbeatProducer.Dispose(); _heartbeatConsumer.Dispose(); CancelLssActivation(); diff --git a/src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs b/src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs index 1946975e..98121ef8 100644 --- a/src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs +++ b/src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs @@ -27,6 +27,17 @@ public sealed class CanOpenNodeOptions /// With copy methods preserve this setting. public bool EmitGuardingErrorEmcy { get; init; } + /// + /// Emit optional 8120h EMCYs when the CAN controller is observed error passive. + /// Defaults to . Uses the shared bus-state monitor (50 ms polling + /// plus error/fault hints). Once observed, the cause remains active through bus-off or an + /// unknown reading until error-active, error-warning or no-error state is observed. + /// It contributes bits 4 and 0 to 1001h; the last repaired cause emits 0000h. + /// + /// Does not enable overrun or recovered-from-bus-off EMCYs. An initially passive + /// controller is reported when the node enters Pre-Operational. Existing With methods preserve this setting. + public bool EmitControllerErrorEmcy { 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. @@ -330,6 +341,7 @@ private CanOpenNodeOptions Copy( var options = new CanOpenNodeOptions { EmitGuardingErrorEmcy = EmitGuardingErrorEmcy, + EmitControllerErrorEmcy = EmitControllerErrorEmcy, 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 a1620283..d590ad46 100644 --- a/src/CanKit.Pro.CANopen/README.md +++ b/src/CanKit.Pro.CANopen/README.md @@ -671,8 +671,29 @@ producer mode. Multiple causes share the same register: only the last repaired c `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. +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 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 90de733d..f4542b1e 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 EmitControllerErrorEmcy { get; init; } public bool EmitGuardingErrorEmcy { get; init; } public bool EnableChangeOfStateSrdo { get; init; } public bool EnableChangeOfStateTpdo { get; init; } diff --git a/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenControllerEmcyTests.cs b/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenControllerEmcyTests.cs new file mode 100644 index 00000000..7cca87f3 --- /dev/null +++ b/tests/CanKit.Pro.Tests/TestCases/CANopen/CanOpenControllerEmcyTests.cs @@ -0,0 +1,486 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using CanKit.Abstractions.API.Can; +using CanKit.Abstractions.API.Can.Definitions; +using CanKit.Abstractions.API.Common.Definitions; +using CanKit.Core.Exceptions; +using CanKit.Pro.CANopen; +using CanKit.Pro.CANopen.Lss; +using CanKit.Pro.CANopen.Nmt; +using CanKit.Pro.RawCan; +using CanKit.Pro.Tests.Infrastructure; +using Xunit; + +namespace CanKit.Pro.Tests.TestCases.CANopen; + +public partial class CanOpenPdoEngineTests +{ + private sealed class ControllerEmcyRig : IDisposable + { + private readonly ICanBus _forwarder; + internal readonly ControllableBus Bus; + internal readonly CanBusService Service; + internal readonly ControllerBusService NodeService; + internal readonly Wire Wire; + internal readonly ManualTimeSource Clock = new(); + internal readonly ICanOpenNode Node; + internal readonly int BaselineErrors; + internal readonly int BaselineFaults; + + internal ControllerEmcyRig(BusState initial = BusState.ErrActive, bool enabled = true, bool unconfiguredLss = false) + { + var session = NewSession(); + Bus = ControllableBus.Plain(session); + _forwarder = Open(session, 1); + Wire = new Wire(session, 2); + Bus.OnTransmitting = frame => _forwarder.Transmit(frame); + Bus.BusState = initial; + Service = new CanBusService(Bus); + BaselineErrors = Bus.ErrorFrameSubscriberCount; + BaselineFaults = Bus.FaultSubscriberCount; + NodeService = new ControllerBusService(Service); + Node = new CanOpenNode(NodeService, unconfiguredLss ? CanOpenCobId.UnconfiguredNodeId : Device, new CanOpenNodeOptions + { + EmitControllerErrorEmcy = enabled, + EmitGuardingErrorEmcy = true, + Lss = unconfiguredLss ? new LssSlaveOptions() : null, + }, ownsService: false, Clock); + Settle(Node); + } + + internal void Observe(BusState state) + { + Bus.BusState = state; + Advance(Clock, Node, TimeSpan.FromMilliseconds(51)); + } + + public void Dispose() + { + Node.Dispose(); + Service.Dispose(); + Wire.Dispose(); + _forwarder.Dispose(); + Bus.Dispose(); + } + } + + private sealed class ControllerBusService : ICanBusService + { + private readonly ICanBusService _inner; + internal Exception? NextBusReadFailure; + internal int BusReads; + internal ControllerBusService(ICanBusService inner) => _inner = inner; + public ICanBus Bus + { + get + { + Interlocked.Increment(ref BusReads); + if (NextBusReadFailure is not { } failure) return _inner.Bus; + NextBusReadFailure = null; + throw failure; + } + } + public int SubscriptionCount => _inner.SubscriptionCount; + public event EventHandler? BackgroundExceptionOccurred + { + add => _inner.BackgroundExceptionOccurred += value; + remove => _inner.BackgroundExceptionOccurred -= value; + } + public ISubscription Subscribe(Func? predicate = null, int? bufferCapacity = null, bool includeEcho = false) + => _inner.Subscribe(predicate, bufferCapacity, includeEcho); + public ISubscription Subscribe(CanIdFilter filter, int? bufferCapacity = null, bool includeEcho = false) + => _inner.Subscribe(filter, bufferCapacity, includeEcho); + public IReadOnlyList FindOverlappingFilterSubscriptions() => _inner.FindOverlappingFilterSubscriptions(); + public Task SendConfirmedAsync(CanFrame frame, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + => _inner.SendConfirmedAsync(frame, timeout, cancellationToken); + public void Dispose() { } + } + + [Theory] + [InlineData(BusState.ErrPassive, true)] + [InlineData(BusState.ErrActive, false)] + [InlineData(BusState.ErrWarning, false)] + [InlineData(BusState.BusOff, false)] + [InlineData(BusState.Unknown, false)] + [InlineData(BusState.None, false)] + public async Task Controller_Emcy_Reports_An_Initially_Passive_Controller_At_Startup(BusState state, bool passive) + { + using var rig = new ControllerEmcyRig(state); + // An initially bus-off controller cannot transmit, so observe recovery before the witness. + if (state == BusState.BusOff) rig.Observe(BusState.ErrActive); + await rig.Wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 1); + if (passive) await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + await rig.Node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, passive ? 2 : 1, 0x1000, passive ? Communication : (byte)0); + rig.Wire.Count(EmcyId).Should().Be(passive ? 2 : 1); + } + + [Fact] + public async Task Controller_Emcy_Default_Off_Does_Not_Add_Monitor_Subscriptions() + { + new CanOpenNodeOptions().EmitControllerErrorEmcy.Should().BeFalse(); + using var rig = new ControllerEmcyRig(enabled: false); + rig.Bus.ErrorFrameSubscriberCount.Should().Be(rig.BaselineErrors); + rig.Bus.FaultSubscriberCount.Should().Be(rig.BaselineFaults); + rig.Observe(BusState.ErrPassive); + await rig.Node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 1, 0x1000, 0); + rig.Wire.Count(EmcyId).Should().Be(1); + } + + [Theory] + [InlineData(BusState.ErrActive)] + [InlineData(BusState.ErrWarning)] + [InlineData(BusState.None)] + public async Task Controller_Emcy_Is_Once_Per_Lapse_And_Recovery_Preserves_Application_Bits(BusState recovery) + { + using var rig = new ControllerEmcyRig(); + await rig.Node.SendEmcyAsync(0x1000, 4); + await ExpectEmcyAsync(rig.Wire, 1, 0x1000, 4); + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 2, 0x8120, 0x15); + rig.Observe(BusState.ErrPassive); + await rig.Node.SendEmcyAsync(0x1000, 4); + await ExpectEmcyAsync(rig.Wire, 3, 0x1000, 0x15); + rig.Observe(recovery); + await ExpectEmcyAsync(rig.Wire, 4, ErrorReset, 4); + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 5, 0x8120, 0x15); + rig.Wire.Count(EmcyId).Should().Be(5); + } + + [Theory] + [InlineData(BusState.BusOff)] + [InlineData(BusState.Unknown)] + public async Task Controller_Emcy_Does_Not_Treat_BusOff_Or_Unknown_As_A_Repair(BusState uncertain) + { + using var rig = new ControllerEmcyRig(); + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + rig.Observe(uncertain); + rig.Node.ObjectDictionary.ReadUnsigned(0x1001, 0).Should().Be(Communication); + rig.Observe(BusState.ErrPassive); + await rig.Node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 2, 0x1000, Communication); + rig.Observe(BusState.ErrActive); + await ExpectEmcyAsync(rig.Wire, 3, ErrorReset, 0); + rig.Wire.Count(EmcyId).Should().Be(3); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Controller_Emcy_And_Heartbeat_Only_Reset_After_Both_Causes_End(bool controllerFirst) + { + using var rig = new ControllerEmcyRig(); + rig.Node.AddHeartbeatConsumer(1, TimeSpan.FromMilliseconds(100)); + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + Advance(rig.Clock, rig.Node, TimeSpan.FromMilliseconds(50)); + await ExpectEmcyAsync(rig.Wire, 2, 0x8130, Communication); + if (controllerFirst) rig.Observe(BusState.ErrActive); + else await Inject(rig.Node, CanOpenCobId.Heartbeat(1), (byte)NmtState.PreOperational); + await rig.Node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 3, 0x1000, Communication); + if (controllerFirst) await Inject(rig.Node, CanOpenCobId.Heartbeat(1), (byte)NmtState.PreOperational); + else rig.Observe(BusState.ErrActive); + await ExpectEmcyAsync(rig.Wire, 4, ErrorReset, 0); + rig.Wire.Count(EmcyId).Should().Be(4); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Controller_Emcy_Reenable_Reports_Only_A_Still_Active_Cause(bool repaired) + { + using var rig = new ControllerEmcyRig(); + rig.Node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId | CanOpenCobId.InvalidBit); + Settle(rig.Node); + rig.Observe(BusState.ErrPassive); + if (repaired) rig.Observe(BusState.ErrActive); + rig.Node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId); + Settle(rig.Node); + if (!repaired) await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + rig.Node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId | CanOpenCobId.InvalidBit); + rig.Node.ObjectDictionary.WriteUnsigned(0x1014, 0, EmcyId); + Settle(rig.Node); + await rig.Node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, repaired ? 1 : 2, 0x1000, repaired ? (byte)0 : Communication); + rig.Wire.Count(EmcyId).Should().Be(repaired ? 1 : 2); + } + + [Fact] + public async Task Controller_Emcy_Reset_Rechecks_The_Physical_Level_After_Bootup() + { + using var rig = new ControllerEmcyRig(); + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + await Inject(rig.Node, CanOpenCobId.NmtCommand, (byte)NmtCommand.ResetCommunication, Device); + await rig.Wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 2); + await ExpectEmcyAsync(rig.Wire, 2, 0x8120, Communication); + rig.Observe(BusState.ErrActive); + await ExpectEmcyAsync(rig.Wire, 3, ErrorReset, 0); + rig.Wire.Count(EmcyId).Should().Be(3); + } + + [Theory] + [InlineData(BusState.ErrActive, NmtCommand.ResetCommunication)] + [InlineData(BusState.ErrWarning, NmtCommand.ResetCommunication)] + [InlineData(BusState.None, NmtCommand.ResetCommunication)] + [InlineData(BusState.BusOff, NmtCommand.ResetCommunication)] + [InlineData(BusState.Unknown, NmtCommand.ResetCommunication)] + [InlineData(BusState.ErrActive, NmtCommand.ResetNode)] + [InlineData(BusState.ErrWarning, NmtCommand.ResetNode)] + [InlineData(BusState.None, NmtCommand.ResetNode)] + [InlineData(BusState.BusOff, NmtCommand.ResetNode)] + [InlineData(BusState.Unknown, NmtCommand.ResetNode)] + public async Task Controller_Emcy_Reset_Takes_A_Fresh_Baseline_Without_A_Poll_Or_Hint(BusState current, NmtCommand command) + { + using var rig = new ControllerEmcyRig(); + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + var beforeReset = rig.Clock.GetTimestamp(); + rig.Bus.BusState = current; + await Inject(rig.Node, CanOpenCobId.NmtCommand, (byte)command, Device); + await rig.Wire.WaitForCountAsync(CanOpenCobId.Heartbeat(Device), 2); + rig.Clock.GetTimestamp().Should().Be(beforeReset); + await rig.Node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 2, 0x1000, 0); + // The replacement monitor must also recognize the next passive edge, even if the + // pre-reset monitor's cached level was already passive. + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 3, 0x8120, Communication); + rig.Wire.Count(EmcyId).Should().Be(3); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task Controller_Emcy_Reset_Retries_A_Failed_Baseline_And_Cleans_Up_The_Retry(bool disposeBeforeRetry, bool disposedRead) + { + using var rig = new ControllerEmcyRig(); + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + rig.Node.BackgroundExceptionOccurred += (_, error) => reported.TrySetResult(error); + rig.NodeService.NextBusReadFailure = disposedRead + ? new ObjectDisposedException(null, "transient controller status read") + : new CanKitException(CanKitErrorCode.NativeCallFailed, "transient controller status read"); + await Inject(rig.Node, CanOpenCobId.NmtCommand, (byte)NmtCommand.ResetCommunication, Device); + (await reported.Task.WaitAsync(ShortTimeout)).Message.Should().Be("transient controller status read"); + if (disposeBeforeRetry) + { + rig.Node.Dispose(); + rig.Bus.ErrorFrameSubscriberCount.Should().Be(rig.BaselineErrors); + rig.Bus.FaultSubscriberCount.Should().Be(rig.BaselineFaults); + return; + } + await rig.Node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 2, 0x1000, 0); + Advance(rig.Clock, rig.Node, TimeSpan.FromMilliseconds(51)); + await ExpectEmcyAsync(rig.Wire, 3, 0x8120, Communication); + rig.Bus.ErrorFrameSubscriberCount.Should().Be(rig.BaselineErrors + 1); + rig.Bus.FaultSubscriberCount.Should().Be(rig.BaselineFaults + 1); + rig.Wire.Count(EmcyId).Should().Be(3); + } + + [Fact] + public async Task Controller_Emcy_Does_Not_Retry_An_Unexpected_Baseline_Error() + { + using var rig = new ControllerEmcyRig(); + rig.Observe(BusState.ErrPassive); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + rig.NodeService.NextBusReadFailure = new InvalidOperationException("unexpected baseline failure"); + var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + rig.Node.BackgroundExceptionOccurred += (_, error) => reported.TrySetResult(error); + await Inject(rig.Node, CanOpenCobId.NmtCommand, (byte)NmtCommand.ResetCommunication, Device); + // The frame-dispatch boundary reports the unexpected exception, without a status retry. + (await reported.Task.WaitAsync(ShortTimeout)).Should().BeOfType(); + Advance(rig.Clock, rig.Node, TimeSpan.FromMilliseconds(51)); + await rig.Node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 2, 0x1000, 0); + rig.Wire.Count(EmcyId).Should().Be(2); + } + + [Theory] + [InlineData("CompleteColdReset")] + [InlineData("CompleteForcedReset")] + public async Task Controller_Emcy_Unexpected_Read_Does_Not_Interrupt_Self_Reset_Echo_Bookkeeping(string complete) + { + using var rig = new ControllerEmcyRig(); + var node = (CanOpenNode)rig.Node; + int resets = 0; + node.ApplicationReset += (_, _) => Interlocked.Increment(ref resets); + var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + node.BackgroundExceptionOccurred += (_, error) => reported.TrySetResult(error); + rig.NodeService.NextBusReadFailure = new InvalidOperationException("unexpected reset baseline"); + await node.PostToActorAsync(() => + { + typeof(CanOpenNode).GetField("_coldResetPending", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(node, true); + typeof(CanOpenNode).GetMethod(complete, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(node, null); + ((bool)typeof(CanOpenNode).GetField("_ignoreBroadcastResetEcho", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(node)!).Should().BeTrue(); + }); + (await reported.Task.WaitAsync(ShortTimeout)).Should().BeOfType(); + await Inject(node, CanOpenCobId.NmtCommand, (byte)NmtCommand.ResetCommunication, 0); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 1, 0x1000, 0); + Volatile.Read(ref resets).Should().Be(1, "the broadcast echo must not apply the self-reset again"); + } + + [Fact] + public async Task Controller_Emcy_Unexpected_Read_Does_Not_Lose_The_Lss_Configure_Reply() + { + using var rig = new ControllerEmcyRig(unconfiguredLss: true); + var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + rig.Node.BackgroundExceptionOccurred += (_, error) => reported.TrySetResult(error); + await Inject(rig.Node, CanOpenCobId.LssMasterMessage, LssCommand.SwitchStateGlobal, 1, 0, 0, 0, 0, 0, 0); + rig.NodeService.NextBusReadFailure = new InvalidOperationException("unexpected LSS baseline"); + await Inject(rig.Node, CanOpenCobId.LssMasterMessage, LssCommand.ConfigureNodeId, Device, 0, 0, 0, 0, 0, 0); + await rig.Wire.WaitForCountAsync(CanOpenCobId.LssSlaveMessage, 1); + rig.Wire.Payloads(CanOpenCobId.LssSlaveMessage)[0].Should().Equal(LssCommand.ConfigureNodeId, 0, 0, 0, 0, 0, 0, 0); + (await reported.Task.WaitAsync(ShortTimeout)).Should().BeOfType(); + rig.Node.NodeId.Should().Be(Device); + rig.Node.State.Should().Be(NmtState.PreOperational); + } + + [Fact] + public async Task Controller_Emcy_Queued_Baseline_Does_Not_Read_After_Actor_Disposal() + { + using var rig = new ControllerEmcyRig(); + var node = (CanOpenNode)rig.Node; + var failure = new InvalidOperationException("must remain unread"); + rig.NodeService.NextBusReadFailure = failure; + Task? tail = null; + await node.PostToActorAsync(() => + { + typeof(CanOpenNode).GetMethod("HandleIncoming", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(node, new object[] { CanOpenCobId.NmtCommand, + new byte[] { (byte)NmtCommand.ResetCommunication, Device }, false }); + tail = node.PostToActorAsync(() => rig.NodeService.NextBusReadFailure.Should().BeSameAs(failure)); + node.Dispose(); + }); + await tail!.WaitAsync(ShortTimeout); + rig.Bus.ErrorFrameSubscriberCount.Should().Be(rig.BaselineErrors); + rig.Bus.FaultSubscriberCount.Should().Be(rig.BaselineFaults); + } + + [Fact] + public async Task Controller_Emcy_Lss_Reset_Invalidates_An_Already_Queued_Baseline() + { + using var rig = new ControllerEmcyRig(initial: BusState.ErrPassive, unconfiguredLss: true); + var node = (CanOpenNode)rig.Node; + await Inject(node, CanOpenCobId.LssMasterMessage, LssCommand.SwitchStateGlobal, 1, 0, 0, 0, 0, 0, 0); + await node.PostToActorAsync(() => + { + var incoming = typeof(CanOpenNode).GetMethod("HandleIncoming", BindingFlags.Instance | BindingFlags.NonPublic)!; + incoming.Invoke(node, new object[] { CanOpenCobId.LssMasterMessage, + new byte[] { LssCommand.ConfigureNodeId, Device, 0, 0, 0, 0, 0, 0 }, false }); + incoming.Invoke(node, new object[] { CanOpenCobId.NmtCommand, + new byte[] { (byte)NmtCommand.ResetNode, Device }, false }); + }); + Settle(node); + node.NodeId.Should().Be(CanOpenCobId.UnconfiguredNodeId); + node.State.Should().Be(NmtState.Initializing); + await Inject(node, CanOpenCobId.LssMasterMessage, LssCommand.SwitchStateGlobal, 1, 0, 0, 0, 0, 0, 0); + await Inject(node, CanOpenCobId.LssMasterMessage, LssCommand.ConfigureNodeId, Device, 0, 0, 0, 0, 0, 0); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 2, 0x1000, Communication); + rig.Wire.Count(EmcyId).Should().Be(2); + } + + [Fact] + public async Task Controller_Emcy_Only_The_Latest_Queued_Reset_May_Read_A_Baseline() + { + using var rig = new ControllerEmcyRig(); + var node = (CanOpenNode)rig.Node; + int reads = Volatile.Read(ref rig.NodeService.BusReads); + await node.PostToActorAsync(() => + { + var incoming = typeof(CanOpenNode).GetMethod("HandleIncoming", BindingFlags.Instance | BindingFlags.NonPublic)!; + incoming.Invoke(node, new object[] { CanOpenCobId.NmtCommand, + new byte[] { (byte)NmtCommand.ResetCommunication, Device }, false }); + incoming.Invoke(node, new object[] { CanOpenCobId.NmtCommand, + new byte[] { (byte)NmtCommand.ResetCommunication, Device }, false }); + }); + Settle(node); + Volatile.Read(ref rig.NodeService.BusReads).Should().Be(reads + 1); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 1, 0x1000, 0); + } + + [Fact] + public async Task Controller_Emcy_Lss_Reset_Invalidates_A_Pending_Baseline_Retry() + { + using var rig = new ControllerEmcyRig(unconfiguredLss: true); + var node = (CanOpenNode)rig.Node; + await Inject(node, CanOpenCobId.LssMasterMessage, LssCommand.SwitchStateGlobal, 1, 0, 0, 0, 0, 0, 0); + await Inject(node, CanOpenCobId.LssMasterMessage, LssCommand.ConfigureNodeId, Device, 0, 0, 0, 0, 0, 0); + Settle(node); + var reported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + node.BackgroundExceptionOccurred += (_, error) => reported.TrySetResult(error); + rig.Bus.BusState = BusState.ErrPassive; + rig.NodeService.NextBusReadFailure = new CanKitException(CanKitErrorCode.NativeCallFailed, "reset retry"); + await Inject(node, CanOpenCobId.NmtCommand, (byte)NmtCommand.ResetCommunication, Device); + (await reported.Task.WaitAsync(ShortTimeout)).Message.Should().Be("reset retry"); + await Inject(node, CanOpenCobId.NmtCommand, (byte)NmtCommand.ResetNode, Device); + Settle(node); + int reads = Volatile.Read(ref rig.NodeService.BusReads); + Advance(rig.Clock, node, TimeSpan.FromMilliseconds(51)); + Volatile.Read(ref rig.NodeService.BusReads).Should().Be(reads); + node.NodeId.Should().Be(CanOpenCobId.UnconfiguredNodeId); + await Inject(node, CanOpenCobId.LssMasterMessage, LssCommand.SwitchStateGlobal, 1, 0, 0, 0, 0, 0, 0); + await Inject(node, CanOpenCobId.LssMasterMessage, LssCommand.ConfigureNodeId, Device, 0, 0, 0, 0, 0, 0); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + await node.SendEmcyAsync(0x1000, 0); + await ExpectEmcyAsync(rig.Wire, 2, 0x1000, Communication); + rig.Wire.Count(EmcyId).Should().Be(2); + } + + [Fact] + public void Controller_Emcy_Monitor_Is_Removed_On_Dispose_And_Failed_Construction() + { + using var rig = new ControllerEmcyRig(); + rig.Bus.ErrorFrameSubscriberCount.Should().Be(rig.BaselineErrors + 1); + rig.Bus.FaultSubscriberCount.Should().Be(rig.BaselineFaults + 1); + rig.Node.Dispose(); + rig.Bus.ErrorFrameSubscriberCount.Should().Be(rig.BaselineErrors); + rig.Bus.FaultSubscriberCount.Should().Be(rig.BaselineFaults); + rig.Service.SubscriptionCount.Should().Be(0); + var failing = new ThrowingSubscribeService(rig.Service, failOnCall: 1); + Action open = () => _ = new CanOpenNode(failing, Device, + new CanOpenNodeOptions { EmitControllerErrorEmcy = true }, ownsService: false, rig.Clock); + open.Should().Throw(); + rig.Bus.ErrorFrameSubscriberCount.Should().Be(rig.BaselineErrors); + rig.Bus.FaultSubscriberCount.Should().Be(rig.BaselineFaults); + rig.Service.SubscriptionCount.Should().Be(0); + } + + [Fact] + public async Task Controller_Emcy_Uses_Fault_Hints_Without_Advancing_The_Poll_Clock() + { + using var rig = new ControllerEmcyRig(); + rig.Bus.BusState = BusState.ErrPassive; + rig.Bus.RaiseFault(new InvalidOperationException("controller hint")); + Settle(rig.Node); + await ExpectEmcyAsync(rig.Wire, 1, 0x8120, Communication); + rig.Clock.GetTimestamp().Should().Be(0); + } + + [Fact] + public void Controller_Emcy_Option_Is_Preserved_By_Existing_Copy_Methods() + { + var options = new CanOpenNodeOptions { EmitControllerErrorEmcy = true }; + options.With(sdoTimeout: TimeSpan.FromSeconds(2)).EmitControllerErrorEmcy.Should().BeTrue(); + options.WithSrdoConfigurationCheckInterval(null).EmitControllerErrorEmcy.Should().BeTrue(); + options.WithLss(new CanKit.Pro.CANopen.Lss.LssSlaveOptions()).EmitControllerErrorEmcy.Should().BeTrue(); + } +}