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();
+ }
+}