Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/requirements/SRS-CanKit.Pro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions src/CanKit.Pro.CANopen/CanOpenNode.CommunicationProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
110 changes: 110 additions & 0 deletions src/CanKit.Pro.CANopen/CanOpenNode.ControllerEmcy.cs
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
{
// 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();
}
Comment thread
dborgards marked this conversation as resolved.
Resolved

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;
}
}
4 changes: 3 additions & 1 deletion src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -189,6 +190,7 @@ private void EndCommunicationErrorsForReset()
{
_communicationErrors.Clear();
_heartbeatEmcyTimeouts.Clear();
InvalidateControllerErrorBaseline();
_heldNodeEmcy = false;
if (_errorRegisterQueued) QueueErrorRegisterWrite(nodeBits: 0);
}
Expand Down
5 changes: 5 additions & 0 deletions src/CanKit.Pro.CANopen/CanOpenNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -323,6 +325,7 @@ CanOpenCobId.FlyingMasterClaim or CanOpenCobId.FlyingMasterTrigger
catch
{
CompleteEventQueue();
DisposeControllerErrorMonitor();
_actor.Dispose();
throw;
}
Expand All @@ -344,6 +347,7 @@ CanOpenCobId.FlyingMasterClaim or CanOpenCobId.FlyingMasterTrigger
if (LayerSettingsBlockBoot()) return;
_state = NmtState.PreOperational;
_ = EmitHeartbeat(0x00);
StartControllerErrorMonitoring();
});

_readerTask = Task.Run(RunReaderAsync);
Expand Down Expand Up @@ -953,6 +957,7 @@ private bool BeginDispose()
/// <summary>Stops the producers and consumers and fails every open transfer. Actor-only.</summary>
private void CleanUpOnActor()
{
DisposeControllerErrorMonitor();
_heartbeatProducer.Dispose();
_heartbeatConsumer.Dispose();
CancelLssActivation();
Expand Down
12 changes: 12 additions & 0 deletions src/CanKit.Pro.CANopen/CanOpenNodeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ public sealed class CanOpenNodeOptions
/// <c>With</c> copy methods preserve this setting.</remarks>
public bool EmitGuardingErrorEmcy { get; init; }

/// <summary>
/// Emit optional <c>8120h</c> EMCYs when the CAN controller is observed error passive.
/// Defaults to <see langword="false"/>. 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 <c>1001h</c>; the last repaired cause emits <c>0000h</c>.
/// </summary>
/// <remarks>Does not enable overrun or recovered-from-bus-off EMCYs. An initially passive
/// controller is reported when the node enters Pre-Operational. Existing <c>With</c> methods preserve this setting.</remarks>
public bool EmitControllerErrorEmcy { get; init; }

/// <summary>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.
Expand Down Expand Up @@ -330,6 +341,7 @@ private CanOpenNodeOptions Copy(
var options = new CanOpenNodeOptions
{
EmitGuardingErrorEmcy = EmitGuardingErrorEmcy,
EmitControllerErrorEmcy = EmitControllerErrorEmcy,
PdoCount = pdoCount ?? PdoCount,
SdoTimeout = sdoTimeout ?? SdoTimeout,
SdoServerTimeout = sdoServerTimeout ?? SdoServerTimeout,
Expand Down
Loading
Loading