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). Solange einer aktiv ist, trägt 1001h Bit 4 und Bit 0; die Bits aus `SendEmcyAsync` und ein von der Applikation (oder einer Gerätebeschreibung) über das Objektverzeichnis geschriebener Wert von 1001h gehören der Applikation und werden ODER-verknüpft, nie vom Knoten gelöscht. Endet der letzte, MUSS der Knoten EMCY 0000h mit dem verbleibenden Register senden (Figure 37, Übergang 4 bzw. 2); ein gehaltenes EMCY (Stopped) trägt das Register seiner Sendung; ein NMT-Reset beendet die Kommunikationsfehler ohne EMCY und lässt die Bits der Applikation stehen; bei abgeschaltetem EMCY (1014h Bit 31) wird nichts ausgelöst, ein Ende löscht das Bit trotzdem. | Must | Unit-Test: EMCY-Frame wird korrekt kodiert/dekodiert inkl. aller Felder. Virtual-Loopback-Integrationstest (`CanOpenErrorRegisterTests`, #379): je Quelle Setzen und Rücksetzen, Überlappung mit Applikationsbits, Reset, abgeschaltetes EMCY, gehaltenes EMCY; `SrdoEngineConsumerTests.A_Short_Pair_Raises_Emcy_8210h_Once_And_Is_Not_Processed` für das Ende eines SRDO-Laufs. | CiA 301 §7.2.7.1 (Figure 37), §7.5.2.2 Table 54 |
| FR-CO-011 | Das System MUSS Emergency-Objekte (EMCY) beim Auftreten interner Fehlerzustände senden und empfangene EMCY-Objekte strukturiert (Error Code, Error Register, Herstellerdaten) an den Applikationsentwickler weiterreichen. Für jedes EMCY, das der Knoten selbst auslöst (8210h RPDO/MPDO-Länge, 8230h MPDO-Ziel, 8240h SYNC-Länge, 8250h RPDO-Timeout, 8210h SRDO-Länge), MUSS er einen Kommunikationsfehler führen: ab dem EMCY, das ihn meldet (einmal je Fehlerereignis), bis seine Ursache endet (passender Frame, SYNC erwarteter Länge, geschriebenes Ziel, Empfang nach dem Timeout, Neukonfiguration von PDO bzw. SRDO). Mit `CanOpenNodeOptions.EmitGuardingErrorEmcy` (Standard `false`) meldet der Knoten optional auch Heartbeat-Consumer- und lokale Life-Guarding-Timeouts als 8130h; wiederholte Timeouts derselben Ursache sind kein neues Fehlerereignis. Empfang bzw. Entfernen/Ändern des Heartbeat-Watches und Life-Guarding-Recovery/-Deaktivierung beenden diese Ursachen. Solange einer aktiv ist, trägt 1001h Bit 4 und Bit 0; die Bits aus `SendEmcyAsync` und ein von der Applikation (oder einer Gerätebeschreibung) über das Objektverzeichnis geschriebener Wert von 1001h gehören der Applikation und werden ODER-verknüpft, nie vom Knoten gelöscht. Endet der letzte, MUSS der Knoten EMCY 0000h mit dem verbleibenden Register senden (Figure 37, Übergang 4 bzw. 2); ein gehaltenes EMCY (Stopped) trägt das Register seiner Sendung; ein NMT-Reset beendet die Kommunikationsfehler ohne EMCY und lässt die Bits der Applikation stehen; bei abgeschaltetem EMCY (1014h Bit 31) wird nichts ausgelöst, ein Ende löscht das Bit trotzdem. | Must | Unit-Test: EMCY-Frame wird korrekt kodiert/dekodiert inkl. aller Felder. Virtual-Loopback-Integrationstest (`CanOpenErrorRegisterTests`, #379): je Quelle Setzen und Rücksetzen, Überlappung mit Applikationsbits, Reset, abgeschaltetes EMCY, gehaltenes EMCY; `CanOpenGuardingEmcyTests` (#423) prüft Opt-in, mehrere Producer, Recovery, Rekonfiguration, Reset und Producer-Deaktivierung; `SrdoEngineConsumerTests.A_Short_Pair_Raises_Emcy_8210h_Once_And_Is_Not_Processed` für das Ende eines SRDO-Laufs. | CiA 301 §7.2.7.1 (Figure 37), §7.5.2.2 Table 54 |
| FR-CO-012 | Das System SOLLTE das L2-Multi-Consumer-Demultiplexing (FR-RAW-010ff.) nutzen, um NMT-, SDO-, PDO- und SYNC-Verkehr eines Node parallel und unabhängig zu verarbeiten. | Should | Integrationstest: gleichzeitiger SDO- und PDO-Verkehr auf demselben Node ohne gegenseitige Verzögerung. | Abgeleitet aus L2-Anforderungen |
| FR-CO-013 | Das System MUSS beim Öffnen eines Knotens die Kommunikationsprofil-Objekte 1000h, 1001h, 1018h (sub0 = 01h, Vendor-ID 0 = „keine Vendor-ID zugeteilt“), 1005h, 1006h, 100Ch, 100Dh, 1010h, 1011h, 1014h, 1016h, 1017h, 1200h sowie die PDO-Records 1400h–1403h, 1600h–1603h, 1800h–1803h und 1A00h–1A03h mit ihren CiA-301-Vorgabewerten im OD anlegen (PDOs „nicht gültig“, Bit 31 gesetzt). Ohne die Option `WritableCommunicationParameters` MÜSSEN die PDO-Records auf dem Bus `ro` sein (Download → 0601 0002h); 1200h ist konstant; der reservierte Sub-Index 04h der PDO-Kommunikationsrecords MUSS fehlen, so dass ein Zugriff 0609 0011h ergibt. | Must | Virtual-Loopback-Integrationstest (`CanOpenCommunicationProfileTests`): SDO-Upload jedes Objekts liefert den Vorgabewert; Download auf 1800h:02 ohne Option → 0601 0002h, mit Option angenommen; Zugriff auf 1800h:04 → 0609 0011h. | CiA 301 §7.5.2.1, §7.5.2.2, §7.5.2.21 und Objektübersicht `ro M` (Posten 3); Fußnote `*` der Objektübersicht (Posten 4, 16); §7.5.2.5, §7.5.2.6, §7.5.2.17 (Posten 19); §7.5.2.11, §7.5.2.12 (Posten 24); §7.5.2.37 zu Sub-Index 04h (Posten 18); Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 4b, 21) für 1200h und 1016h/1017h |
| FR-CO-014 | Das System MUSS das OD als einzige Quelle der Kommunikationskonfiguration führen, in beiden Richtungen und auf beiden Schreibpfaden: ein Wert, den das OD für ein vom Knoten verwaltetes Objekt annimmt — per SDO-Download wie per lokalem Schreibzugriff über `ObjectDictionary` —, MUSS im zugehörigen Dienst (SYNC, EMCY, Heartbeat, Life Guarding, PDO) wirken; `StartHeartbeatProducer`, `AddHeartbeatConsumer`, `StartSyncProducer`, `SendEmcyAsync` (1001h), `ConfigureTpdo` und `ConfigureRpdo` MÜSSEN als Schreibzugriffe auf diese Objekte ausgeführt werden; ein nicht umsetzbarer Wert MUSS vor dem Speichern abgewiesen werden (SDO: Abort-Code, lokal: `ArgumentException` mit demselben Code im Text); ein verwaltetes Objekt darf per `Add*` nicht neu deklariert werden (`InvalidOperationException`). | Must | Virtual-Loopback-Integrationstest (`CanOpenCommunicationProfileTests`): 1017h per SDO gesetzt startet den Heartbeat; `StartSyncProducer` schreibt 1006h und Bit 30 von 1005h; `SendEmcyAsync` spiegelt 1001h; `AddU32(0x1006, …)` wirft; lokaler Schreibzugriff mit ungültigem Wert wirft mit dem Abort-Code im Text. | Architekturentscheidung (Scope-Dokument 2026-09-15, Posten 14, 20, 22, 23) |
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 @@ -732,6 +732,7 @@ private void ApplyEmcyConfiguration()
uint word = _od.ReadUnsigned(Co.EmcyCobId, 0x00);
_emcyCobId = word & CanOpenCobId.CanIdMask;
_emcyValid = (word & CanOpenCobId.InvalidBit) == 0;
ReportActiveGuardingErrors();
}

// §7.5.2.20: "The value 0 shall disable the producer heartbeat." The producer module follows
Expand Down Expand Up @@ -761,6 +762,7 @@ private void RebuildHeartbeatConsumers()
desired[nodeId] = TimeSpan.FromMilliseconds(ms);
}

ReconcileHeartbeatEmcyWatches(desired);
_heartbeatConsumer.Replace(desired);
}

Expand Down
46 changes: 44 additions & 2 deletions src/CanKit.Pro.CANopen/CanOpenNode.ErrorRegister.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CanKit.Pro.CANopen.Emcy;
Expand All @@ -10,8 +11,9 @@ namespace CanKit.Pro.CANopen;
// FR-CO-011, #379: the error register (1001h) and the EMCY error state of CiA 301 §7.2.7.1.
//
// One rule for every EMCY the node raises itself — RPDO and MPDO length 8210h, MPDO destination
// 8230h, SYNC length 8240h, RPDO timeout 8250h, SRDO length 8210h. Each is a communication error
// (Table 26, 82xxh "Protocol error" under 80xxh "Monitoring"), so while one is active the register
// 8230h, SYNC length 8240h, RPDO timeout 8250h, SRDO length 8210h, and opt-in heartbeat/life-
// guarding timeout 8130h. Each is a communication error
// (Table 26, 81xxh/82xxh under 80xxh "Monitoring"), so while one is active the register
// carries bit 4, "Communication error", and bit 0, because "The generic error shall be signaled at
// any error situation" (§7.5.2.2, Table 54). The error is active from the EMCY that reports it —
// "transmitted only once per 'error event'" — until its cause ends: a frame that fits, a SYNC of
Expand Down Expand Up @@ -40,6 +42,8 @@ private enum CommunicationError
RpdoTimeout = 3,
SyncLength = 4,
SrdoLength = 5,
HeartbeatTimeout = 6,
LifeGuardingTimeout = 7,
}

// The application's part of 1001h: what its last transmitted SendEmcyAsync passed, or what it
Expand All @@ -59,6 +63,43 @@ private enum CommunicationError
// The communication errors the node reported and whose cause has not ended. Actor loop only.
private readonly HashSet<int> _communicationErrors = new();

// Last timeout of each still-silent watched producer; retained while EMCY is disabled so
// enabling the producer can report an error that is still active. Actor loop only.
private readonly Dictionary<byte, TimeSpan> _heartbeatEmcyTimeouts = new();

private void NoteHeartbeatForMonitoring(byte producer)
{
_heartbeatConsumer.NoteReceived(producer);
if (!_options.EmitGuardingErrorEmcy) return;
_heartbeatEmcyTimeouts.Remove(producer);
EndCommunicationError(CommunicationError.HeartbeatTimeout, producer);
}

private void ReconcileHeartbeatEmcyWatches(IReadOnlyDictionary<byte, TimeSpan> desired)
{
if (!_options.EmitGuardingErrorEmcy) return;
foreach (var previous in _heartbeatEmcyTimeouts
.Where(WatchChanged)
.ToList())
{
_heartbeatEmcyTimeouts.Remove(previous.Key);
EndCommunicationError(CommunicationError.HeartbeatTimeout, previous.Key);
}

bool WatchChanged(KeyValuePair<byte, TimeSpan> previous)
=> !desired.TryGetValue(previous.Key, out var timeout) || timeout != previous.Value;
}

private void ReportActiveGuardingErrors()
{
if (!_options.EmitGuardingErrorEmcy || !_emcyValid) return;
foreach (var producer in _heartbeatEmcyTimeouts.Keys)
RaiseCommunicationError(CommunicationError.HeartbeatTimeout, producer, 0x8130);
if (_lifeGuardingOccurred)
RaiseCommunicationError(CommunicationError.LifeGuardingTimeout, 0, 0x8130);
}


// Whether an EMCY was ever queued: from then on, a reset writes the register it leaves
// behind, in its place on the chain. Before, 1001h holds what the node created it with, or
// what the application wrote to it. Actor loop only.
Expand Down Expand Up @@ -147,6 +188,7 @@ private void EmitPendingEmcy(EmcyMessage pending)
private void EndCommunicationErrorsForReset()
{
_communicationErrors.Clear();
_heartbeatEmcyTimeouts.Clear();
_heldNodeEmcy = false;
if (_errorRegisterQueued) QueueErrorRegisterWrite(nodeBits: 0);
}
Expand Down
9 changes: 9 additions & 0 deletions src/CanKit.Pro.CANopen/CanOpenNode.NodeGuarding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,19 @@ private void ResetLifeGuardingState()
_lifeGuardingDeadline?.Dispose();
_lifeGuardingDeadline = null;
_lifeGuardingOccurred = false;
if (_options.EmitGuardingErrorEmcy)
EndCommunicationError(CommunicationError.LifeGuardingTimeout);
}

private void RaiseLifeGuardingEvent(LifeGuardingState state)
{
if (_options.EmitGuardingErrorEmcy)
{
if (state == LifeGuardingState.Occurred)
RaiseCommunicationError(CommunicationError.LifeGuardingTimeout, 0, 0x8130);
else
EndCommunicationError(CommunicationError.LifeGuardingTimeout);
}
var args = new LifeGuardingEventArgs(state, _guardTime, _lifeTimeFactor);
EnqueueEvent(() =>
{
Expand Down
9 changes: 7 additions & 2 deletions src/CanKit.Pro.CANopen/CanOpenNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1567,7 +1567,7 @@ private void HandleIncomingFrame(uint cobId, byte[] data, bool isRtr, bool isFd)
{
byte guardedState = (byte)(data[0] & 0x7F);
NoteSlaveNmtState(producer, guardedState);
_heartbeatConsumer.NoteReceived(producer);
NoteHeartbeatForMonitoring(producer);
}
return;
}
Expand Down Expand Up @@ -1778,7 +1778,7 @@ private void HandleHeartbeat(uint cobId, byte[] data)
if ((data[0] & 0x80) == 0 && TryDecodeHeartbeatState(stateByte, out var state))
RaiseHeartbeatReceived(producer, state, DateTime.UtcNow);
NoteSlaveNmtState(producer, stateByte);
_heartbeatConsumer.NoteReceived(producer);
NoteHeartbeatForMonitoring(producer);
}

// =========================================================================================
Expand Down Expand Up @@ -3043,6 +3043,11 @@ private void RaiseHeartbeatReceived(byte producer, NmtState state, DateTime ts)

private void RaiseHeartbeatTimeout(byte producer, TimeSpan timeout)
{
if (_options.EmitGuardingErrorEmcy)
{
_heartbeatEmcyTimeouts[producer] = timeout;
RaiseCommunicationError(CommunicationError.HeartbeatTimeout, producer, 0x8130);
}
var args = new HeartbeatTimeoutEventArgs(producer, timeout);
EnqueueEvent(() =>
{
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 @@ -16,6 +16,17 @@ namespace CanKit.Pro.CANopen;
/// </remarks>
public sealed class CanOpenNodeOptions
{
/// <summary>
/// Emit optional <c>8130h</c> EMCYs for heartbeat-consumer timeouts and this node's life-
/// guarding timeout. Defaults to <see langword="false"/>; timeout events remain available
/// regardless. Enabled errors contribute bits 4 and 0 to <c>1001h</c> until reception,
/// removal/reconfiguration of the watch, a guarding poll or disabling life guarding ends
/// their cause. The last repaired communication error emits <c>0000h</c>.
/// </summary>
/// <remarks>Does not enable controller-state or node-guarding-consumer EMCYs. Existing
/// <c>With</c> copy methods preserve this setting.</remarks>
public bool EmitGuardingErrorEmcy { 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 @@ -318,6 +329,7 @@ private CanOpenNodeOptions Copy(
{
var options = new CanOpenNodeOptions
{
EmitGuardingErrorEmcy = EmitGuardingErrorEmcy,
PdoCount = pdoCount ?? PdoCount,
SdoTimeout = sdoTimeout ?? SdoTimeout,
SdoServerTimeout = sdoServerTimeout ?? SdoServerTimeout,
Expand Down
17 changes: 14 additions & 3 deletions src/CanKit.Pro.CANopen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ may be written in any order, so a slot not yet overwritten does not count agains
## Error control

**Error register (`1001h`) and error reset.** Every EMCY the node raises itself is a
communication error (CiA 301 Table 26, `82xxh`) and follows one rule (§7.2.7.1 and Figure 37;
communication error (CiA 301 Table 26, `81xxh`/`82xxh`) and follows one rule (§7.2.7.1 and Figure 37;
§7.5.2.2, Table 54): `8210h` for an RPDO, MPDO or SRDO shorter than its mapping, `8230h` for an
MPDO destination that is not available, `8240h` for a SYNC of the wrong length and `8250h` for an
RPDO deadline. Each is sent once per error event. While any of them is active the register
Expand All @@ -660,8 +660,19 @@ errors without an EMCY (transitions 5 and 0) and leave the application's bits in
the EMCY switched off (bit 31 of `1014h`) no error is raised, and an error that ends meanwhile
still clears the bits. A run of short RPDO frames or SRDO pairs is not consumed while disabled:
if it persists after enabling, the next short frame/pair reports `8210h` once. Toggling the
producer after that report does not start a new error event (#434). Heartbeat, node-guarding and life-guarding timeouts and the CAN
controller state raise no EMCY (`8110h`–`8140h`); they are events only.
producer after that report does not start a new error event (#434).

`CanOpenNodeOptions.EmitGuardingErrorEmcy = true` opts into `8130h` for heartbeat-consumer
and local life-guarding timeouts (#423); the default is `false`, so existing timeout events
keep their behavior without new EMCYs. A silent heartbeat producer is one error event until
reception or removal/change of its watch; repeated timeouts do not duplicate it. Life guarding
recovers with the next poll, disabling guard time/life-time factor, or switching to heartbeat
producer mode. Multiple causes share the same register: only the last repaired cause sends
`0000h`, retaining application bits. An active timeout while `1014h` disables the producer is
reported once if still active when it is enabled; a recovery while disabled is not replayed.
NMT reset clears this monitoring error state without an error-reset EMCY. Existing options
copy methods preserve the opt-in. Node-guarding-consumer timeouts and CAN controller states
still raise events only; the remaining optional `8110h`/`8120h`/`8140h` feature work stays in #423.

**Heartbeat.** The producer and the consumer are separate modules. `HeartbeatProducer` sends
this node's heartbeat; `HeartbeatConsumer` watches other nodes. Neither module references the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ namespace CanKit.Pro.CANopen
{
public CanOpenNodeOptions() { }
public System.TimeSpan DefaultTpdoEventTimerInterval { get; init; }
public bool EmitGuardingErrorEmcy { get; init; }
public bool EnableChangeOfStateSrdo { get; init; }
public bool EnableChangeOfStateTpdo { get; init; }
public int EventQueueCapacity { get; init; }
Expand Down
Loading
Loading