From 0c3a32fc099da1230a48e59db407a7d9a039616a Mon Sep 17 00:00:00 2001 From: Dietmar Borgards Date: Mon, 26 Oct 2026 06:26:46 +0100 Subject: [PATCH 1/3] feat(canopen): monitor foreign SRDO and GFC captures passively --- docs/requirements/SRS-CanKit.Pro-en.md | 1 + docs/requirements/SRS-CanKit.Pro.md | 1 + src/CanKit.Pro.CANopen/README.md | 94 ++++- .../Safety/ForeignSrdoConfiguration.cs | 75 ++++ .../Safety/ForeignSrdoMonitor.cs | 321 +++++++++++++++ .../Safety/ForeignSrdoObservation.cs | 146 +++++++ .../CanKit.Pro.CANopen.approved.txt | 86 ++++ .../Safety/ForeignSrdoConfigurationTests.cs | 109 +++++ .../CANopen/Safety/ForeignSrdoMonitorTests.cs | 386 ++++++++++++++++++ 9 files changed, 1216 insertions(+), 3 deletions(-) create mode 100644 src/CanKit.Pro.CANopen/Safety/ForeignSrdoConfiguration.cs create mode 100644 src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs create mode 100644 src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs create mode 100644 tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/ForeignSrdoConfigurationTests.cs create mode 100644 tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/ForeignSrdoMonitorTests.cs diff --git a/docs/requirements/SRS-CanKit.Pro-en.md b/docs/requirements/SRS-CanKit.Pro-en.md index 53435b8c..8f0d0f92 100644 --- a/docs/requirements/SRS-CanKit.Pro-en.md +++ b/docs/requirements/SRS-CanKit.Pro-en.md @@ -360,6 +360,7 @@ As-is state (2026-10-09): shipped as `CanKit.Pro.CANopen`. The scope of the devi | FR-CO-058 | As SRD per CiA 319 v2.0.0 §5.3 step 4 the system MUST, on the write of A5h to 13FEh — via SDO, locally or from a device description —, recalculate the checksum of every existing SRDO (sub1 ≠ 0 — also a reserved value 3..255, which only a corruption leaves behind, counts as existing and fails the checksum; a deleted one has no configuration to protect, and §9.5 does not check it, as FR-CO-044) over the record as it stands in the dictionary — all mapping slots raw, the inverted ones included, not synthesised from the plain slots (`SrdoRecords.RecordChecksum`; the same computation applies to FR-CO-038) —, compare it with 13FFh:n and on a deviation set 13FEh to 00h at once, in the same write, so that the configuration entity's readback (FR-CO-051, step 4) sees it. Per §5.4 the same check MUST run after Reset Node and Reset Communication over the restored values before the boot-up is sent; the values themselves are restored unchanged (FR-CO-035), only 13FEh is cleared on a deviation. The check on the transition to OPERATIONAL (FR-CO-038) remains. | Must | Virtual-loopback integration test (`CanOpenSafetyNodeTests.Writing_A5h_Over_A_Wrong_Checksum_Sets_13FEh_Back_To_Zero_At_Once`: deviation → 00h, matching again → A5h, a deleted SRDO is not compared; `A_Stored_Configuration_That_Does_Not_Match_Its_Checksum_Comes_Back_Invalid`: a record changed behind the dictionary — the refresh time, an inverted mapping slot, or the direction to a reserved value — is stored with A5h, restored and 13FEh cleared in the reset pass; `CanOpenSafetyDeviceDescriptionTests.A_Wrong_Checksum_In_The_File_Loads_Without_A_Finding_But_The_Configuration_Is_Not_Valid`: the file as power-on state). | CiA 319 v2.0.0 §5.3 step 4, §5.4; #308 | | FR-CO-059 | The system MUST, with `CanOpenNodeOptions.SrdoConfigurationCheckInterval` (default null = off; a value ≤ 0 → `ArgumentOutOfRangeException`), repeat the check of FR-CO-058 periodically as long as 13FEh = A5h (CiA 319 v2.0.0 §5.3: "the SRD should validate the consistency of its safety-related configuration by periodically recalculating the CRC" — a "should", and the norm names no interval, hence opt-in); additionally it MUST compare the checksum-relevant objects of the SRDOs existing at the time (record, mapping, 13FFh:n; a deleted SRDO has nothing to protect and is not included) byte-wise with the image taken under the write gate on the A5h write or after the reset — every write to one of them clears 13FEh, so every deviation from it while A5h stands arose behind the dictionary, also a direction that now reads 0 or reserved; the engine's view is not consulted, because a regular deletion with commit may pre-empt it. A deviation sets 13FEh to 00h and MUST in OPERATIONAL invalidate every SRDO as on the transition (FR-CO-038) — `ConfigurationInvalid`, or no longer present (reason null) if its record reads as deleted — and end the send period as leaving OPERATIONAL does (FR-CO-039): neither a waiting pair nor one already queued on the send chain nor the inverted half of a running one goes out; a consumer reports invalid. `CanOpenNodeOptions.With` keeps the interval, `WithSrdoConfigurationCheckInterval` replaces it (null = off). The norm provides no notification of the configuration entity; `SrdoStateChanged` reports the transition. A reset does not end the check, dispose does. | Must | Virtual-loopback integration test with manual clock (`CanOpenSafetyNodeTests.The_Periodic_Check_Finds_A_Corrupted_Record_And_Stops_The_Producer`): a check without finding changes nothing; a record changed behind the dictionary — refresh time, direction 0, direction 3 — is found on the next one, 13FEh = 00h, `ConfigurationInvalid`, no further pair; `A_Pending_Pair_Is_Dropped_When_The_Periodic_Check_Invalidates_The_Srdo`: one waiting behind an unconfirmed pair no longer goes out after the invalidation, nor does the inverted half of the running one; `A_Pair_Already_Queued_Behind_A_Held_One_Is_Dropped_When_The_Periodic_Check_Invalidates`: an already queued pair of a second SRDO likewise; `A_Delete_Followed_By_A_Commit_Survives_The_Periodic_Check`: delete plus commit is no deviation; `A_Change_Behind_The_Dictionary_In_A_Deleted_Srdos_Record_Is_Not_A_Mismatch`: the record of a deleted SRDO is not in the image; `A_Mismatch_Found_Outside_Operational_Clears_13FEh_And_Leaves_The_Engine_To_The_Transition`: outside OPERATIONAL only 13FEh; `SafetyTypesTests.The_Check_Interval_Is_Kept_By_With_And_Replaced_By_Its_Own_With`. | CiA 319 v2.0.0 §5.3; interval decision; #308 | | FR-CO-060 | The system MUST offer optional read-only peer Safety configuration acquisition compatible with existing Safety implementations. A bound EDS/DCF limits and validates uploads; an expected configuration is optional. The immutable snapshot retains raw entries, provenance, identity, acquisition interval, independent validity/signature/expected outcomes, repeated-read changes and unconfirmed atomic consistency. Partial/fallback data cannot silently become a complete verified image. No downloads or state-changing commands are sent. Monitoring eligibility is explicit; replacing a snapshot begins a new evaluation period. Existing CRC, mapping and CiA 319 SRVT rules apply, with #289 limitations visible. | Must | Deterministic controlled-SDO tests: complete/partial/invalid/changing records, expectation, fallback, channel isolation, cancellation, immutability and upload-only traffic; source mutations. | [#403](https://github.com/dborgards/CanKit.Pro/issues/403) | +| FR-CO-061 | The system MUST continuously monitor foreign SRDOs and GFCs receive-only from prepared immutable configuration, without a local Operational node, SDO traffic, OD writes or other transmissions. Pairing, inversion, format, length, SRVT/SCT, startup, recovery, configuration replacement and current values are explicit. Host capture timestamps separate assessment from UI delivery latency; insufficient timing and known capture gaps invalidate pending pairs and current values. Capture quality remains separate from protocol status. Each GFC occurrence retains bus/time without sender attribution or safe-state claims. Provenance and historical values cannot silently become current data or safety approval. | Must | Controlled clock and timestamped individual frames: interleaving, pair/format errors, deadline boundaries, gaps, recovery, replacement, repeated GFC, disposal, zero TX; source mutations. | [#401](https://github.com/dborgards/CanKit.Pro/issues/401) | #### 4.3.3 J1939 (application) diff --git a/docs/requirements/SRS-CanKit.Pro.md b/docs/requirements/SRS-CanKit.Pro.md index df2acc42..261ccb8d 100644 --- a/docs/requirements/SRS-CanKit.Pro.md +++ b/docs/requirements/SRS-CanKit.Pro.md @@ -358,6 +358,7 @@ Ist-Zustand (09.10.2026): ausgeliefert als `CanKit.Pro.CANopen`. Der Zuschnitt d | FR-CO-058 | Das System MUSS als SRD nach CiA 319 v2.0.0 §5.3 Schritt 4 beim Schreiben von A5h auf 13FEh — per SDO, lokal oder aus einer Gerätebeschreibung — die Prüfsumme jedes bestehenden SRDO (sub1 ≠ 0 — auch ein reservierter Wert 3..255, den nur eine Verfälschung hinterlässt, zählt als bestehend und fällt durch die Prüfsumme; ein gelöschtes hat keine zu schützende Konfiguration, und §9.5 prüft es nicht, wie FR-CO-044) über den Record, wie er im Verzeichnis steht — alle Mapping-Slots roh, die invertierten eingeschlossen, nicht aus den Klartext-Slots synthetisiert (`SrdoRecords.RecordChecksum`; dieselbe Berechnung gilt für FR-CO-038) — neu berechnen, mit 13FFh:n vergleichen und bei einer Abweichung 13FEh sofort, im selben Write, auf 00h setzen, sodass das Zurücklesen der Konfigurationsinstanz (FR-CO-051, Schritt 4) es sieht. Nach §5.4 MUSS dieselbe Prüfung nach Reset Node und Reset Communication über die zurückgestellten Werte laufen, bevor der Boot-up gesendet wird; die Werte selbst werden unverändert zurückgestellt (FR-CO-035), nur 13FEh wird bei Abweichung gelöscht. Die Prüfung beim Übergang nach OPERATIONAL (FR-CO-038) bleibt. | Must | Virtual-Loopback-Integrationstest (`CanOpenSafetyNodeTests.Writing_A5h_Over_A_Wrong_Checksum_Sets_13FEh_Back_To_Zero_At_Once`: Abweichung → 00h, wieder passend → A5h, ein gelöschtes SRDO wird nicht verglichen; `A_Stored_Configuration_That_Does_Not_Match_Its_Checksum_Comes_Back_Invalid`: ein hinter dem Verzeichnis geänderter Record — die Refresh-Zeit, ein invertierter Mapping-Slot, oder die Richtung zu einem reservierten Wert — wird mit A5h gespeichert, zurückgestellt und 13FEh im Reset-Durchlauf gelöscht; `CanOpenSafetyDeviceDescriptionTests.A_Wrong_Checksum_In_The_File_Loads_Without_A_Finding_But_The_Configuration_Is_Not_Valid`: die Datei als Power-on-Zustand). | CiA 319 v2.0.0 §5.3 Schritt 4, §5.4; #308 | | FR-CO-059 | Das System MUSS mit `CanOpenNodeOptions.SrdoConfigurationCheckInterval` (Vorgabe null = aus; ein Wert ≤ 0 → `ArgumentOutOfRangeException`) die Prüfung aus FR-CO-058 periodisch wiederholen, solange 13FEh = A5h ist (CiA 319 v2.0.0 §5.3: „the SRD should validate the consistency of its safety-related configuration by periodically recalculating the CRC“ — ein „should“, und die Norm nennt kein Intervall, daher Opt-in); zusätzlich MUSS sie die prüfsummenrelevanten Objekte der damals bestehenden SRDOs (Record, Mapping, 13FFh:n; ein gelöschtes SRDO hat nichts zu schützen und ist nicht enthalten) byteweise mit dem Abbild vergleichen, das beim A5h-Write bzw. nach dem Reset unter dem Schreibtor aufgenommen wurde — jeder Write auf eines davon löscht 13FEh, also ist jede Abweichung davon, solange A5h steht, hinter dem Verzeichnis entstanden, auch eine Richtung, die jetzt 0 oder reserviert liest; die Sicht der Engine wird nicht herangezogen, weil ein reguläres Löschen mit Commit ihr zuvorkommen kann. Eine Abweichung setzt 13FEh auf 00h und MUSS in OPERATIONAL jedes SRDO ungültig machen wie beim Übergang (FR-CO-038) — `ConfigurationInvalid`, bzw. nicht mehr vorhanden (Grund null), wenn sein Record als gelöscht liest — und die Sendeperiode beenden wie das Verlassen von OPERATIONAL (FR-CO-039): weder ein wartendes noch ein schon auf der Sendekette eingereihtes Paar noch die invertierte Hälfte eines laufenden geht hinaus; ein Consumer meldet ungültig. `CanOpenNodeOptions.With` behält das Intervall, `WithSrdoConfigurationCheckInterval` ersetzt es (null = aus). Die Norm sieht keine Benachrichtigung der Konfigurationsinstanz vor; `SrdoStateChanged` meldet den Übergang. Ein Reset beendet die Prüfung nicht, das Dispose schon. | Must | Virtual-Loopback-Integrationstest mit manueller Uhr (`CanOpenSafetyNodeTests.The_Periodic_Check_Finds_A_Corrupted_Record_And_Stops_The_Producer`): eine Prüfung ohne Befund ändert nichts; ein hinter dem Verzeichnis geänderter Record — Refresh-Zeit, Richtung 0, Richtung 3 — wird bei der nächsten gefunden, 13FEh = 00h, `ConfigurationInvalid`, kein weiteres Paar; `A_Pending_Pair_Is_Dropped_When_The_Periodic_Check_Invalidates_The_Srdo`: ein hinter einem unbestätigten Paar wartendes geht nach der Entwertung nicht mehr hinaus, die invertierte Hälfte des laufenden auch nicht; `A_Pair_Already_Queued_Behind_A_Held_One_Is_Dropped_When_The_Periodic_Check_Invalidates`: ein schon eingereihtes Paar eines zweiten SRDO ebenso; `A_Delete_Followed_By_A_Commit_Survives_The_Periodic_Check`: Löschen plus Commit ist keine Abweichung; `A_Change_Behind_The_Dictionary_In_A_Deleted_Srdos_Record_Is_Not_A_Mismatch`: der Record eines gelöschten SRDO ist nicht im Abbild; `A_Mismatch_Found_Outside_Operational_Clears_13FEh_And_Leaves_The_Engine_To_The_Transition`: außerhalb von OPERATIONAL nur 13FEh; `SafetyTypesTests.The_Check_Interval_Is_Kept_By_With_And_Replaced_By_Its_Own_With`. | CiA 319 v2.0.0 §5.3; Intervall Entscheidung; #308 | | FR-CO-060 | Das System MUSS eine optionale, zu bestehenden Safety-Implementierungen kompatible, nur lesende Erfassung der Safety-Konfiguration eines Peers anbieten. Eine gebundene EDS/DCF begrenzt und validiert Uploads; eine Sollkonfiguration ist optional. Der unveränderliche Snapshot bewahrt Rohwerte, Herkunft, Identität, Erfassungszeitraum, getrennte Gültigkeits-/Signatur-/Sollvergleiche, beobachtete Änderungen und unbestätigte atomare Konsistenz. Teil- und Ersatzdaten dürfen nicht stillschweigend zum vollständigen geprüften Geräteabbild werden. Downloads und zustandsändernde Befehle sind ausgeschlossen. Die Eignung als Monitoreingabe ist explizit; ein Snapshotwechsel beginnt einen neuen Auswertungszeitraum. Bestehende CRC-, Mapping- und CiA-319-SRVT-Regeln gelten mit sichtbaren Einschränkungen aus #289. | Must | Deterministische Tests mit gesteuerten SDO-Antworten: vollständige/unvollständige/ungültige/wechselnde Datensätze, Sollvergleich, Ersatzwerte, Kanalisolierung, Abbruch, Unveränderlichkeit und reine Upload-Kommunikation; Quellcode-Mutationen. | [#403](https://github.com/dborgards/CanKit.Pro/issues/403) | +| FR-CO-061 | Das System MUSS fremde SRDOs und GFCs kontinuierlich und rein empfangend auf vorbereiteten unveränderlichen Konfigurationen auswerten, ohne lokalen Operational-Knoten, SDO-Verkehr, OD-Schreibzugriffe oder andere Sendungen. Paarbildung, Inversion, Format, Länge, SRVT/SCT, Startup, Wiederanlauf, Konfigurationswechsel und aktuelle Werte müssen explizit sein. Host-Empfangszeitstempel entkoppeln Bewertung von UI-Latenz; unzureichende Zeitbasis und bekannte Empfangslücken entwerten ausstehende Paare und aktuelle Werte. Capture-Qualität bleibt vom Protokollstatus getrennt. Jede GFC-Erfassung bleibt mit Bus/Zeit, ohne Senderzuordnung oder Safe-State-Aussage erhalten. Provenienz und historische Werte dürfen nicht als aktuelle Daten oder Safety-Freigabe erscheinen. | Must | Kontrollierte Uhr und gestempelte Einzel-Frames: Interleaving, Paar-/Formatfehler, Fristgrenzen, Lücken, Wiederanlauf, Wechsel, GFC-Wiederholungen, Disposal, Null-TX; Quellcode-Mutationen. | [#401](https://github.com/dborgards/CanKit.Pro/issues/401) | #### 4.3.3 J1939 (Applikation) diff --git a/src/CanKit.Pro.CANopen/README.md b/src/CanKit.Pro.CANopen/README.md index 066fdf71..0985d4b8 100644 --- a/src/CanKit.Pro.CANopen/README.md +++ b/src/CanKit.Pro.CANopen/README.md @@ -1328,9 +1328,97 @@ and disqualify pending monitoring input. Identical answers still mean `Unconfirm atomic device image. Missing or malformed required recheck evidence disqualifies input too. `ConfigurationId` is a new identity for every acquisition, not a device-provided version; `StartedAtUtc` and monotonic `Duration` record the acquisition interval. The returned collections and bytes are immutable. -Replacing a monitor's snapshot must begin a new evaluation period. Continuous receive-only -supervision and its protocol recovery policy are separate work in #401; none of these diagnostics -is safety approval. +Replacing a monitor's snapshot begins a new evaluation period. None of these diagnostics is +safety approval. + +### Continuous passive SRDO and GFC monitoring + +`ForeignSrdoMonitor` (#401) receives individual frames from a private RawCan subscription and +supervises independent SRDO pairs on one capture bus. Open one monitor per bus. It requires no +local CANopen node, NMT state, safety object dictionary or process-data sink. It has no send path: +no SDO, NMT, heartbeat, EMCY, SRDO or GFC is transmitted. Prepare configuration separately using +a known EDS/DCF mapping or an explicit read-only acquisition described above. + +```csharp +// SCT and SRVT are explicit observer limits, not a producer's refresh period or CRC default. +var input = ForeignSrdoConfiguration.FromSnapshot(image, number: 1, + validationTime: TimeSpan.FromMilliseconds(10), + safeguardCycleTime: TimeSpan.FromMilliseconds(100)); +using var monitor = ForeignSrdoMonitor.Open(service, "adapter/channel-1", new[] { input }); +var result = monitor.Poll(); +foreach (var item in result.Observations) + Console.WriteLine($"{item.BusIdentity} {item.HostTimestamp}: {item.Status}, {item.CaptureQuality}"); +foreach (var state in result.States) + Console.WriteLine($"SRDO {state.Configuration.Number}: current={state.IsCurrent}, age={state.Age}"); +// If HasBacklog is true, poll again until caught up before displaying current values. +``` + +`ForeignSrdoConfiguration` copies and validates a byte-aligned mapping (up to eight bytes), +consecutive standard odd/even COB-IDs and positive observer limits (SRVT up to 255 ms, SCT up +to 65535 ms). Its provenance is mandatory. Invalid configuration throws before monitoring; +invalid replacement leaves the existing session intact. `FromSnapshot` requires an eligible +record and preserves the whole immutable acquisition, raw provenance and its configuration ID. +A receiver record supplies its consumer SCT/SRVT unless overridden. A producer record requires +**both explicit observer limits**: its refresh time and checksum SRVT/default are not inferred +as monitoring deadlines. Configuration node IDs associate a mapping; they do not authenticate +the sender of a received frame. Duplicate peer/number keys or overlapping COB-IDs on one bus +are rejected as ambiguous. An empty configuration still monitors GFCs. + +The pairing rules follow the existing local consumer, using its inversion and mapping helpers: +an initial lone inverted frame is reported as `StartupInverted`, with the initial SCT still +running. A normal frame replaces a pending normal and restarts SRVT; values are unavailable +while waiting. Equal-length inverse pairs are decoded only if long enough for the mapping; +extra bytes within classic CAN's eight-byte limit participate in inversion but are not decoded. +Short pairs, mismatches, wrong order and invalid formats discard pending/current values without +resetting SCT. SRVT expires at the boundary; SCT also expires at its boundary and wins a tie +with that SRDO's SRVT. Expiry items retain the logical deadline timestamp. A fresh usable pair +recovers and restarts SCT. No received values are written to an object dictionary. + +Timing uses `CanFrameEvent.HostArrivalTimestamp`, captured by RawCan before buffering, on the +same process's `Stopwatch` clock. Adapter `ReceiveTimestamp` is retained for trace correlation; +it is not used for arithmetic or comparisons across buses. `Poll` advances deadlines between +captured frames, then to the current clock only after draining. UI refresh rate or delayed +delivery therefore does not turn an on-time buffered pair into a late pair. A bounded batch +reports `HasBacklog` conservatively even if the final input exactly filled it, with no current +signals until a subsequent drain. Missing/zero, future, backward, or already-assessed late host +timestamps produce `TimingUnavailable`, discard pending/current values and suspend timing until +fresh input establishes a new baseline. This cannot measure unknown driver/adapter delays. +States have `AsOfTimestamp`; retained instances are historical. `Age` describes the last valid +pair, including after a fault; `Signals` is empty unless the state is current. A fault never +silently reuses the last process value. + +`ReplaceConfigurations` starts a fresh evaluation period and clears all previous values and +timers. Queued SRDO frames older than replacement are reported as `BeforeEvaluationPeriod`; +they cannot be decoded with the new mapping. Frames stamped exactly at replacement belong to +the new period; the host clock cannot distinguish events within one tick. Every observation +and state retains configuration/evaluation identities so an application can correlate changes. + +With `ILossAwareSubscription` (#402), enqueue gaps and durable drop counts qualify capture +separately from SRDO status. A gap discards every pending pair/current value and starts a new +window; it cannot assemble a normal frame before a gap with an inverted frame after it. A +stable fresh complete pair can restore that SRDO window to `Contiguous`; the session trace +remains `Incomplete`, because lost GFCs or other frames cannot be recovered. A counter change +that cannot be located by sequence, or occurs during a poll, conservatively invalidates even +a pair reconstructed in that poll. A later stable fresh pair recovers. Counter saturation +does not defeat sequence checks. An externally supplied subscription must have one exclusive +reader from its creation; shared readers make gaps ambiguous. Legacy inputs retain `Unknown` +quality and a null drop count. `Contiguous` means no detected subscription loss, never proof +of complete driver or bus capture. An expiry reports absence in this capture window, never +a confirmed remote device failure, especially with incomplete input. + +Each captured classic, base-ID `001h`, non-RTR, non-error, zero-length GFC produces a distinct +`GfcObserved` trace item with bus identity and capture timestamps, including host echoes. CAN FD, +extended, RTR, error or non-empty candidates yield `MalformedGfc`. GFCs have no associated +configuration/node: the wire format carries no sender identity. They are independent of SRDO +pair state and never imply that a device entered its safe state. All items are returned directly +in bounded poll results; there is no secondary event buffer silently dropping repetitions. +Disposal stops analysis and closes only an owned subscription; the shared service stays alive. + +The local consumer remains the device-role implementation with NMT, OD actuation and EMCY. +`ObserveForeignSrdoAsync` remains the existing one-pair decoder with optional SDO preparation +and caller-owned timing. The continuous monitor consumes already prepared input. These +diagnostic observations make no safety approval or SIL claim; unresolved interoperability +questions tracked in #289 remain open. ### What this is not diff --git a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoConfiguration.cs b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoConfiguration.cs new file mode 100644 index 00000000..084128f6 --- /dev/null +++ b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoConfiguration.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CanKit.Pro.CANopen.Pdo; + +namespace CanKit.Pro.CANopen.Safety; + +/// Immutable, explicitly prepared input for a passive SRDO monitor. Construction sends nothing. +public sealed class ForeignSrdoConfiguration +{ + /// Copies and validates a mapping and explicit observer SRVT/SCT. Peer identity is a + /// configuration association, never sender identification from the wire. Provenance names the + /// application's source (for example an approved DCF revision). + public ForeignSrdoConfiguration(byte peerNodeId, int number, uint cobId1, uint cobId2, + IEnumerable mapping, TimeSpan validationTime, TimeSpan safeguardCycleTime, + string provenance) + { + if (peerNodeId is < 1 or > 127) throw new ArgumentOutOfRangeException(nameof(peerNodeId)); + if (number is < 1 or > 64) throw new ArgumentOutOfRangeException(nameof(number)); + if (!SrdoFrames.IsCobIdPair(cobId1, cobId2)) throw new ArgumentException("Expected a consecutive DS304 odd/even COB-ID pair.", nameof(cobId1)); + if (validationTime <= TimeSpan.Zero || validationTime > TimeSpan.FromMilliseconds(255)) + throw new ArgumentOutOfRangeException(nameof(validationTime)); + if (safeguardCycleTime <= TimeSpan.Zero || safeguardCycleTime > TimeSpan.FromMilliseconds(65535)) + throw new ArgumentOutOfRangeException(nameof(safeguardCycleTime)); + if (mapping is null) throw new ArgumentNullException(nameof(mapping)); + if (string.IsNullOrWhiteSpace(provenance)) throw new ArgumentException("Configuration provenance is required.", nameof(provenance)); + var validated = new SrdoMapping(); + foreach (var entry in mapping) validated.Add(entry); + Mapping = Array.AsReadOnly(validated.ToArray()); + TotalBytes = validated.TotalBytes; + PeerNodeId = peerNodeId; Number = number; CobId1 = cobId1; CobId2 = cobId2; + ValidationTime = validationTime; SafeguardCycleTime = safeguardCycleTime; + Provenance = provenance; ConfigurationId = Guid.NewGuid(); + } + + /// Creates observer input from an eligible snapshot. Producer refresh time and + /// checksum SRVT defaults are not observer deadlines: both overrides are required for a + /// producer. Receiver records can supply their explicit consumer timings. + public static ForeignSrdoConfiguration FromSnapshot(PeerSafetySnapshot snapshot, int number, + TimeSpan? validationTime = null, TimeSpan? safeguardCycleTime = null) + { + if (snapshot is null) throw new ArgumentNullException(nameof(snapshot)); + var record = snapshot.Srdos.SingleOrDefault(s => s.Number == number); + if (record is not { EligibleForMonitoring: true, Parameter: { } parameter }) + throw new ArgumentException("The snapshot has no eligible SRDO with this number.", nameof(number)); + if (parameter.Direction == SrdoDirection.Transmit && (!validationTime.HasValue || !safeguardCycleTime.HasValue)) + throw new ArgumentException("Producer snapshots require explicit observer SRVT and SCT.", nameof(snapshot)); + return new ForeignSrdoConfiguration(snapshot.PeerNodeId, number, parameter.CobId1, parameter.CobId2, + record.Mapping, validationTime ?? parameter.ValidationTime, + safeguardCycleTime ?? parameter.RefreshOrSafeguardCycleTime, "PeerSafetySnapshot") + { SourceSnapshot = snapshot, ConfigurationId = snapshot.ConfigurationId }; + } + + /// Prepared configuration identity, or the source acquisition's identity. + public Guid ConfigurationId { get; private set; } + /// Configuration owner, not authenticated wire sender. + public byte PeerNodeId { get; } + /// SRDO number in that configuration. + public int Number { get; } + /// Normal frame identifier. + public uint CobId1 { get; } + /// Inverted frame identifier. + public uint CobId2 { get; } + /// Owned, validated mapping in payload order. + public IReadOnlyList Mapping { get; } + /// Observer SRVT. + public TimeSpan ValidationTime { get; } + /// Observer SCT. + public TimeSpan SafeguardCycleTime { get; } + /// Explicit application source description. + public string Provenance { get; } + /// Full acquisition and per-entry provenance when created from a snapshot. + public PeerSafetySnapshot? SourceSnapshot { get; private set; } + internal int TotalBytes { get; } +} diff --git a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs new file mode 100644 index 00000000..6c2d8da2 --- /dev/null +++ b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CanKit.Abstractions.API.Can.Definitions; +using CanKit.Abstractions.API.Common.Definitions; +using CanKit.Pro.Actor; +using CanKit.Pro.RawCan; + +namespace CanKit.Pro.CANopen.Safety; + +/// Passive continuous SRDO/GFC analysis (FR-CO-061), independent of any CANopen node or object dictionary. +/// +/// This monitor has only a receive subscription. It sends nothing, performs no SDO acquisition, +/// and never actuates process data. Polls are serialized with replacement and disposal. +/// HostArrivalTimestamp must be captured before buffering, in the same process's Stopwatch +/// clock. Adapter timestamps are retained only as metadata. Poll drains captured frames before +/// advancing to the current clock, so UI scheduling does not change pair timing. Missing, late, +/// future or backward timestamps explicitly invalidate timing. Upstream driver latency and losses +/// are not measurable here. Results are diagnostics, never remote-failure confirmation or safety approval. +/// The input must have one exclusive reader for its entire lifetime. Ordered loss metadata +/// is used when available; legacy subscriptions remain of unknown capture quality. Even a +/// contiguous subscription cannot prove complete capture at the adapter or on the bus. +/// +public sealed class ForeignSrdoMonitor : IDisposable +{ + private static readonly IReadOnlyList NoSignals = Array.Empty(); + private readonly object _gate = new(); + private readonly ISubscription _subscription; + private readonly ILossAwareSubscription? _lossAware; + private readonly bool _ownsSubscription; + private readonly ITimeSource _clock; + private readonly string _bus; + private Runtime[] _runtimes; + private Dictionary _routes; + private ForeignSrdoCaptureQuality _traceQuality; + private Guid _period = Guid.NewGuid(); + private ulong _expectedSequence = 1; + private long _dropCount; + private long _watermark; + private long _configurationStart; + private bool _disposed; + + /// Uses an existing, exclusively read subscription. Ownership is explicit; the + /// caller must include all configured identifiers and 001h, including malformed candidates. + public ForeignSrdoMonitor(ISubscription subscription, string busIdentity, + IEnumerable configurations, bool ownsSubscription = false) + : this(subscription, busIdentity, configurations, ownsSubscription, MonotonicTimeSource.Instance) { } + + internal ForeignSrdoMonitor(ISubscription subscription, string busIdentity, + IEnumerable configurations, bool ownsSubscription, ITimeSource clock) + { + _subscription = subscription ?? throw new ArgumentNullException(nameof(subscription)); + if (string.IsNullOrWhiteSpace(busIdentity)) throw new ArgumentException("A capture bus identity is required.", nameof(busIdentity)); + _bus = busIdentity; _ownsSubscription = ownsSubscription; _clock = clock; + _lossAware = subscription as ILossAwareSubscription; + _traceQuality = _lossAware is null ? ForeignSrdoCaptureQuality.Unknown : ForeignSrdoCaptureQuality.Contiguous; + _configurationStart = _watermark = clock.GetTimestamp(); + (_runtimes, _routes) = Prepare(configurations, _watermark); + } + + /// Opens and owns a private subscription, including host echoes (the monitor has no + /// transmissions of its own). Disposal closes only that subscription, never the shared service. + public static ForeignSrdoMonitor Open(ICanBusService service, string busIdentity, + IEnumerable configurations, int? bufferCapacity = null) + { + if (service is null) throw new ArgumentNullException(nameof(service)); + var subscription = service.Subscribe(e => (uint)e.Frame.ID is 1 or >= 0x101 and <= 0x180, + bufferCapacity, includeEcho: true); + try { return new ForeignSrdoMonitor(subscription, busIdentity, configurations, ownsSubscription: true); } + catch { subscription.Dispose(); throw; } + } + + /// Atomically replaces prepared input and starts a fresh evaluation period. Queued + /// SRDOs captured before replacement are reported and discarded; GFC trace items are retained. + /// Invalid/ambiguous configurations throw before changing the running session. + public void ReplaceConfigurations(IEnumerable configurations) + { + lock (_gate) + { + ThrowIfDisposed(); + long now = _clock.GetTimestamp(); + var prepared = Prepare(configurations, now); + (_runtimes, _routes) = prepared; + _period = Guid.NewGuid(); + _configurationStart = _watermark = now; + } + } + + /// Drains at most maxFrames captured inputs, returning all observations directly. + /// A full batch conservatively reports backlog and does not advance deadlines to wall time; + /// poll again until HasBacklog is false. No callbacks run on the capture thread. + public ForeignSrdoPollResult Poll(int maxFrames = 4096) + { + if (maxFrames < 1) throw new ArgumentOutOfRangeException(nameof(maxFrames)); + lock (_gate) + { + ThrowIfDisposed(); + var items = new List(); + long before = _lossAware?.DroppedFrameCount ?? 0; + bool counterChanged = before != _dropCount; + if (counterChanged) Gap(items, _watermark); + bool sequenceGap = false; + int count = 0; + while (count < maxFrames && TryRead(out var input, out var sequence)) + { + count++; + if (sequence.HasValue) + { + if (sequence.Value != _expectedSequence) + { + sequenceGap = true; + Gap(items, _watermark); + } + _expectedSequence = unchecked(sequence.Value + 1); + } + Process(input, sequence, items); + } + bool backlog = count == maxFrames; + long after = _lossAware?.DroppedFrameCount ?? 0; + // A counter alone cannot locate an eviction relative to frames read concurrently. + // Unless an ordered gap accounted for a stable count, discard any reconstructed pair. + if (after != before || (counterChanged && !sequenceGap)) Gap(items, _watermark); + _dropCount = after; + if (!backlog) Advance(_clock.GetTimestamp(), items); + return new ForeignSrdoPollResult(items.ToArray(), _runtimes.Select(rt => Snapshot(rt, !backlog)).ToArray(), + backlog, _traceQuality, _lossAware is null ? null : after); + } + } + + /// Stops analysis and, if owned, disposes its subscription. Idempotent. + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + if (_ownsSubscription) _subscription.Dispose(); + } + } + + private (Runtime[], Dictionary) Prepare(IEnumerable configurations, long now) + { + if (configurations is null) throw new ArgumentNullException(nameof(configurations)); + var runtimes = new List(); + var routes = new Dictionary(); + var keys = new HashSet<(byte, int)>(); + foreach (var config in configurations) + { + if (config is null) throw new ArgumentException("A configuration cannot be null.", nameof(configurations)); + if (!keys.Add((config.PeerNodeId, config.Number)) || routes.ContainsKey(config.CobId1) || routes.ContainsKey(config.CobId2)) + throw new ArgumentException("Ambiguous SRDO association or overlapping COB-IDs on one bus.", nameof(configurations)); + var runtime = new Runtime(config, InitialQuality(), TickMath.DueAt(now, config.SafeguardCycleTime, _clock.Frequency)); + runtimes.Add(runtime); routes.Add(config.CobId1, runtime); routes.Add(config.CobId2, runtime); + } + return (runtimes.ToArray(), routes); + } + + private bool TryRead(out CanFrameEvent input, out ulong? sequence) + { + if (_lossAware is not null) + { + bool found = _lossAware.TryReadWithSequence(out var envelope); + input = envelope.FrameEvent; sequence = envelope.SequenceNumber; + return found; + } + sequence = null; + return _subscription.TryRead(out input); + } + + private void Process(CanFrameEvent input, ulong? sequence, List items) + { + var frame = input.Frame; + uint id = (uint)frame.ID; + bool gfc = id == 1; + if (!gfc && !_routes.ContainsKey(id)) return; + long timestamp = input.HostArrivalTimestamp; + bool timing = timestamp > 0 && timestamp >= _watermark && timestamp <= _clock.GetTimestamp(); + bool classic = frame.FrameKind == CanFrameType.Can20 && !frame.IsExtendedFrame && !frame.IsRemoteFrame && !frame.IsErrorFrame && frame.Data.Length <= 8; + if (gfc) + { + if (timing) Advance(timestamp, items); + // GFC is independent of all SRDO state, local NMT and configuration count. + items.Add(Item(classic && frame.Data.Length == 0 ? ForeignSrdoStatus.GfcObserved : ForeignSrdoStatus.MalformedGfc, + timestamp, input.ReceiveTimestamp, sequence, null, _traceQuality, timing, NoSignals)); + return; + } + var rt = _routes[id]; + if (timestamp > 0 && timestamp < _configurationStart) + { + items.Add(Item(ForeignSrdoStatus.BeforeEvaluationPeriod, timestamp, input.ReceiveTimestamp, + sequence, rt.Config, rt.Quality, false, NoSignals)); + return; + } + if (!timing) + { + _period = Guid.NewGuid(); + foreach (var affected in _runtimes) + { + Invalidate(affected, ForeignSrdoStatus.TimingUnavailable); + affected.Sct = null; affected.Timing = false; + } + items.Add(Item(ForeignSrdoStatus.TimingUnavailable, timestamp, input.ReceiveTimestamp, + sequence, rt.Config, rt.Quality, false, NoSignals)); + return; + } + Advance(timestamp, items); + if (!rt.Timing) + { + rt.Sct = TickMath.DueAt(timestamp, rt.Config.SafeguardCycleTime, _clock.Frequency); + rt.Timing = true; + } + if (!classic) Invalidate(rt, ForeignSrdoStatus.InvalidFormat); + else if (id == rt.Config.CobId1) + { + rt.Pending = frame.Data.ToArray(); + rt.Srvt = TickMath.DueAt(timestamp, rt.Config.ValidationTime, _clock.Frequency); + rt.Signals = NoSignals; + rt.Status = ForeignSrdoStatus.WaitingForInverted; + } + else if (rt.Pending is not { } normal) + { + if (!rt.PairSeen && rt.Status is ForeignSrdoStatus.NotReceived or ForeignSrdoStatus.StartupInverted) + rt.Status = ForeignSrdoStatus.StartupInverted; + else Invalidate(rt, ForeignSrdoStatus.OutOfOrder); + } + else + { + rt.PairSeen = true; rt.Pending = null; rt.Srvt = null; + if (!SrdoFrames.IsInversePair(normal, frame.Data.ToArray())) Invalidate(rt, ForeignSrdoStatus.Mismatch); + else if (normal.Length < rt.Config.TotalBytes) Invalidate(rt, ForeignSrdoStatus.ShortPayload); + else + { + var signals = new List(); + int offset = 0; + foreach (var mapping in rt.Config.Mapping) + { + var value = new byte[mapping.ByteLength]; + Buffer.BlockCopy(normal, offset, value, 0, value.Length); + offset += value.Length; + signals.Add(new ForeignSrdoSignal(mapping.Index, mapping.Subindex, value)); + } + rt.Signals = Array.AsReadOnly(signals.ToArray()); rt.Status = ForeignSrdoStatus.Valid; + rt.LastValid = timestamp; rt.Quality = InitialQuality(); + rt.Sct = TickMath.DueAt(timestamp, rt.Config.SafeguardCycleTime, _clock.Frequency); + } + } + items.Add(Item(rt.Status, timestamp, input.ReceiveTimestamp, sequence, rt.Config, rt.Quality, true, rt.Signals)); + } + + private void Advance(long target, List items) + { + // Process the earliest deadline across all SRDOs; SCT wins an exact tie, as the + // existing consumer arms SCT first. Expiry at the boundary precedes a captured frame. + while (true) + { + Runtime? next = null; + long due = long.MaxValue; + bool sct = false; + foreach (var rt in _runtimes) + { + if (rt.Sct is { } cycle && cycle <= target && (next is null || cycle < due)) + { next = rt; due = cycle; sct = true; } + if (rt.Srvt is { } validation && validation <= target && (next is null || validation < due)) + { next = rt; due = validation; sct = false; } + } + if (next is null) break; + if (sct) next.Sct = null; + Invalidate(next, sct ? ForeignSrdoStatus.SafeguardCycleExpired : ForeignSrdoStatus.ValidationTimeExpired); + items.Add(Item(next.Status, due, null, null, next.Config, next.Quality, next.Timing, NoSignals)); + } + _watermark = Math.Max(_watermark, target); + } + + private void Gap(List items, long timestamp) + { + _traceQuality = ForeignSrdoCaptureQuality.Incomplete; + _period = Guid.NewGuid(); + foreach (var rt in _runtimes) + { + Invalidate(rt, ForeignSrdoStatus.CaptureGap); + rt.Quality = ForeignSrdoCaptureQuality.Incomplete; + rt.PairSeen = false; rt.Sct = null; rt.Timing = false; + } + items.Add(Item(ForeignSrdoStatus.CaptureGap, timestamp, null, null, null, _traceQuality, false, NoSignals)); + } + + private static void Invalidate(Runtime rt, ForeignSrdoStatus reason) + { rt.Pending = null; rt.Srvt = null; rt.Status = reason; rt.Signals = NoSignals; } + + private ForeignSrdoCaptureQuality InitialQuality() => _lossAware is null + ? ForeignSrdoCaptureQuality.Unknown : ForeignSrdoCaptureQuality.Contiguous; + + private ForeignSrdoObservation Item(ForeignSrdoStatus status, long timestamp, TimeSpan? receive, + ulong? sequence, ForeignSrdoConfiguration? config, ForeignSrdoCaptureQuality quality, bool timing, + IReadOnlyList signals) + => new(_bus, status, timestamp, receive, sequence, config, _period, quality, timing, signals); + + private ForeignSrdoState Snapshot(Runtime rt, bool caughtUp) + => new(rt.Config, _period, rt.Status, rt.Quality, _watermark, rt.LastValid, + rt.LastValid is { } valid ? TickMath.ElapsedFromTicks(_watermark - valid, _clock.Frequency) : null, + caughtUp && rt.Status == ForeignSrdoStatus.Valid ? rt.Signals : NoSignals, caughtUp); + + private void ThrowIfDisposed() + { if (_disposed) throw new ObjectDisposedException(nameof(ForeignSrdoMonitor)); } + + private sealed class Runtime + { + internal Runtime(ForeignSrdoConfiguration config, ForeignSrdoCaptureQuality quality, long sct) + { Config = config; Quality = quality; Sct = sct; } + internal readonly ForeignSrdoConfiguration Config; + internal ForeignSrdoStatus Status = ForeignSrdoStatus.NotReceived; + internal ForeignSrdoCaptureQuality Quality; + internal IReadOnlyList Signals = NoSignals; + internal byte[]? Pending; + internal long? Sct; + internal long? Srvt; + internal long? LastValid; + internal bool PairSeen; + internal bool Timing = true; + } +} diff --git a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs new file mode 100644 index 00000000..5603b5a8 --- /dev/null +++ b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; + +namespace CanKit.Pro.CANopen.Safety; + +/// What the monitor observed, rather than a remote device's safety state. +public enum ForeignSrdoStatus +{ + /// No complete pair in this evaluation period. + NotReceived, + /// A complete, usable inverse pair within the observed timing limits. + Valid, + /// A normal frame is pending; also reports replacement by another normal frame. + WaitingForInverted, + /// A lone inverted frame at startup was ignored, matching local consumer semantics. + StartupInverted, + /// A lone inverted frame after startup or a fault. + OutOfOrder, + /// Payload lengths or bitwise inversion differ. + Mismatch, + /// A pair is shorter than the mapping. + ShortPayload, + /// Extended, RTR, FD or oversized SRDO candidate. + InvalidFormat, + /// No inverted frame was observed before SRVT elapsed. + ValidationTimeExpired, + /// No usable pair was observed before SCT elapsed. + SafeguardCycleExpired, + /// Known input discontinuity; pending and current data were discarded. + CaptureGap, + /// Host capture timing is absent, late, future or non-monotonic. + TimingUnavailable, + /// A buffered SRDO predates the current configuration/evaluation period. + BeforeEvaluationPeriod, + /// One captured valid GFC, without node attribution. + GfcObserved, + /// One malformed candidate on identifier 001h. + MalformedGfc, +} + +/// Capture evidence, independent of pair status. No value proves upstream completeness. +public enum ForeignSrdoCaptureQuality +{ + /// The input has no ordered loss metadata. + Unknown, + /// No subscription-local discontinuity has been observed in this window. + Contiguous, + /// A sequence gap or reported drop has occurred. + Incomplete, +} + +/// Owned immutable bytes of one decoded object, in CANopen little-endian order. +public sealed class ForeignSrdoSignal +{ + internal ForeignSrdoSignal(ushort index, byte subindex, byte[] value) + { Index = index; Subindex = subindex; Value = Array.AsReadOnly(value); } + /// Mapped object index. + public ushort Index { get; } + /// Mapped object sub-index. + public byte Subindex { get; } + /// Owned read-only bytes. + public IReadOnlyList Value { get; } +} + +/// Immutable trace item. Every captured GFC occurrence gets its own item. +public sealed class ForeignSrdoObservation +{ + internal ForeignSrdoObservation(string bus, ForeignSrdoStatus status, long timestamp, + TimeSpan? receiveTimestamp, ulong? sequence, ForeignSrdoConfiguration? configuration, + Guid period, ForeignSrdoCaptureQuality quality, bool timingAvailable, + IReadOnlyList signals) + { + BusIdentity = bus; Status = status; HostTimestamp = timestamp; ReceiveTimestamp = receiveTimestamp; + SequenceNumber = sequence; Configuration = configuration; EvaluationPeriodId = period; + CaptureQuality = quality; TimingAvailable = timingAvailable; Signals = signals; + } + /// Caller-supplied identity of this physical capture bus. + public string BusIdentity { get; } + /// Observation category. + public ForeignSrdoStatus Status { get; } + /// Capture time, or logical expiry time, in Stopwatch ticks. Zero denotes missing time. + public long HostTimestamp { get; } + /// Original adapter timestamp for frame events, without cross-bus arithmetic. + public TimeSpan? ReceiveTimestamp { get; } + /// Subscription-local sequence, when available and frame-associated. + public ulong? SequenceNumber { get; } + /// Configuration association; null for GFCs and capture-wide findings. Never wire sender authentication. + public ForeignSrdoConfiguration? Configuration { get; } + /// Evaluation window; replacement or discontinuity starts a new window. + public Guid EvaluationPeriodId { get; } + /// Input quality for this observation's window. + public ForeignSrdoCaptureQuality CaptureQuality { get; } + /// Whether the host timestamp can be used for this assessment. + public bool TimingAvailable { get; } + /// Decoded values only on a valid-pair item; faults never repeat old values. + public IReadOnlyList Signals { get; } +} + +/// State as of one poll. A retained instance is historical, not a live clock. +public sealed class ForeignSrdoState +{ + internal ForeignSrdoState(ForeignSrdoConfiguration configuration, Guid period, ForeignSrdoStatus status, + ForeignSrdoCaptureQuality quality, long asOf, long? lastValid, TimeSpan? age, + IReadOnlyList signals, bool caughtUp) + { + Configuration = configuration; EvaluationPeriodId = period; Status = status; CaptureQuality = quality; + AsOfTimestamp = asOf; LastValidTimestamp = lastValid; Age = age; Signals = signals; + IsCurrent = caughtUp && status == ForeignSrdoStatus.Valid; + } + /// Immutable configuration and provenance. + public ForeignSrdoConfiguration Configuration { get; } + /// Current evaluation window. + public Guid EvaluationPeriodId { get; } + /// Latest pair/timing state. + public ForeignSrdoStatus Status { get; } + /// Quality since this evaluation window began; recovery requires a fresh complete pair. + public ForeignSrdoCaptureQuality CaptureQuality { get; } + /// Monotonic timestamp through which this state has been evaluated. + public long AsOfTimestamp { get; } + /// Last usable pair, retained as historical metadata across faults, but cleared on replacement. + public long? LastValidTimestamp { get; } + /// Age of that last pair at AsOfTimestamp, even when no longer current. + public TimeSpan? Age { get; } + /// True only for a valid state after the input has been drained. + public bool IsCurrent { get; } + /// Current values only; empty after a fault, while waiting, or while catching up. + public IReadOnlyList Signals { get; } +} + +/// Bounded poll result. Items are returned directly without a lossy callback/event buffer. +public sealed class ForeignSrdoPollResult +{ + internal ForeignSrdoPollResult(ForeignSrdoObservation[] observations, ForeignSrdoState[] states, + bool backlog, ForeignSrdoCaptureQuality quality, long? drops) + { Observations = Array.AsReadOnly(observations); States = Array.AsReadOnly(states); HasBacklog = backlog; CaptureQuality = quality; DroppedFrameCount = drops; } + /// Trace entries in input/deadline order; GFCs are never coalesced. + public IReadOnlyList Observations { get; } + /// One state per prepared SRDO. + public IReadOnlyList States { get; } + /// The batch limit was reached. Poll again before claiming current values or wall-clock expiry. + public bool HasBacklog { get; } + /// Sticky session trace quality; a known gap cannot be erased by later traffic. + public ForeignSrdoCaptureQuality CaptureQuality { get; } + /// Durable subscription drop count, or null for a legacy input. + public long? DroppedFrameCount { get; } +} 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 66fe6a3b..f6de3916 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.CANopen.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.CANopen.approved.txt @@ -877,12 +877,98 @@ namespace CanKit.Pro.CANopen.Safety public static System.Threading.Tasks.Task ReadPeerSafetyConfigurationAsync(this CanKit.Pro.CANopen.Safety.ICanOpenSafety safety, byte peerNodeId, CanKit.Pro.CANopen.Safety.PeerSafetyConfiguration? expected = null, System.Threading.CancellationToken cancellationToken = default) { } public static CanKit.Pro.CANopen.Safety.ICanOpenSafety Safety(this CanKit.Pro.CANopen.ICanOpenNode node) { } } + public enum ForeignSrdoCaptureQuality + { + Unknown = 0, + Contiguous = 1, + Incomplete = 2, + } + public sealed class ForeignSrdoConfiguration + { + public ForeignSrdoConfiguration(byte peerNodeId, int number, uint cobId1, uint cobId2, System.Collections.Generic.IEnumerable mapping, System.TimeSpan validationTime, System.TimeSpan safeguardCycleTime, string provenance) { } + public uint CobId1 { get; } + public uint CobId2 { get; } + public System.Guid ConfigurationId { get; } + public System.Collections.Generic.IReadOnlyList Mapping { get; } + public int Number { get; } + public byte PeerNodeId { get; } + public string Provenance { get; } + public System.TimeSpan SafeguardCycleTime { get; } + public CanKit.Pro.CANopen.Safety.PeerSafetySnapshot? SourceSnapshot { get; } + public System.TimeSpan ValidationTime { get; } + public static CanKit.Pro.CANopen.Safety.ForeignSrdoConfiguration FromSnapshot(CanKit.Pro.CANopen.Safety.PeerSafetySnapshot snapshot, int number, System.TimeSpan? validationTime = default, System.TimeSpan? safeguardCycleTime = default) { } + } + public sealed class ForeignSrdoMonitor : System.IDisposable + { + public ForeignSrdoMonitor(CanKit.Pro.RawCan.ISubscription subscription, string busIdentity, System.Collections.Generic.IEnumerable configurations, bool ownsSubscription = false) { } + public void Dispose() { } + public CanKit.Pro.CANopen.Safety.ForeignSrdoPollResult Poll(int maxFrames = 4096) { } + public void ReplaceConfigurations(System.Collections.Generic.IEnumerable configurations) { } + public static CanKit.Pro.CANopen.Safety.ForeignSrdoMonitor Open(CanKit.Pro.RawCan.ICanBusService service, string busIdentity, System.Collections.Generic.IEnumerable configurations, int? bufferCapacity = default) { } + } + public sealed class ForeignSrdoObservation + { + public string BusIdentity { get; } + public CanKit.Pro.CANopen.Safety.ForeignSrdoCaptureQuality CaptureQuality { get; } + public CanKit.Pro.CANopen.Safety.ForeignSrdoConfiguration? Configuration { get; } + public System.Guid EvaluationPeriodId { get; } + public long HostTimestamp { get; } + public System.TimeSpan? ReceiveTimestamp { get; } + public ulong? SequenceNumber { get; } + public System.Collections.Generic.IReadOnlyList Signals { get; } + public CanKit.Pro.CANopen.Safety.ForeignSrdoStatus Status { get; } + public bool TimingAvailable { get; } + } public sealed class ForeignSrdoObserveResult { public uint CobId1 { get; } public CanKit.Pro.CANopen.ForeignPdoObservation? Observation { get; } public string? Reason { get; } } + public sealed class ForeignSrdoPollResult + { + public CanKit.Pro.CANopen.Safety.ForeignSrdoCaptureQuality CaptureQuality { get; } + public long? DroppedFrameCount { get; } + public bool HasBacklog { get; } + public System.Collections.Generic.IReadOnlyList Observations { get; } + public System.Collections.Generic.IReadOnlyList States { get; } + } + public sealed class ForeignSrdoSignal + { + public ushort Index { get; } + public byte Subindex { get; } + public System.Collections.Generic.IReadOnlyList Value { get; } + } + public sealed class ForeignSrdoState + { + public System.TimeSpan? Age { get; } + public long AsOfTimestamp { get; } + public CanKit.Pro.CANopen.Safety.ForeignSrdoCaptureQuality CaptureQuality { get; } + public CanKit.Pro.CANopen.Safety.ForeignSrdoConfiguration Configuration { get; } + public System.Guid EvaluationPeriodId { get; } + public bool IsCurrent { get; } + public long? LastValidTimestamp { get; } + public System.Collections.Generic.IReadOnlyList Signals { get; } + public CanKit.Pro.CANopen.Safety.ForeignSrdoStatus Status { get; } + } + public enum ForeignSrdoStatus + { + NotReceived = 0, + Valid = 1, + WaitingForInverted = 2, + StartupInverted = 3, + OutOfOrder = 4, + Mismatch = 5, + ShortPayload = 6, + InvalidFormat = 7, + ValidationTimeExpired = 8, + SafeguardCycleExpired = 9, + CaptureGap = 10, + TimingUnavailable = 11, + BeforeEvaluationPeriod = 12, + GfcObserved = 13, + MalformedGfc = 14, + } public sealed class GlobalFailsafeCommandReceivedEventArgs : System.EventArgs { public GlobalFailsafeCommandReceivedEventArgs(System.DateTime timestamp) { } diff --git a/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/ForeignSrdoConfigurationTests.cs b/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/ForeignSrdoConfigurationTests.cs new file mode 100644 index 00000000..7bdf2896 --- /dev/null +++ b/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/ForeignSrdoConfigurationTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using AwesomeAssertions; +using CanKit.Pro.CANopen.Pdo; +using CanKit.Pro.CANopen.Safety; +using CanKit.Pro.RawCan; +using Xunit; + +namespace CanKit.Pro.Tests.TestCases.CANopen.Safety; + +public class ForeignSrdoConfigurationTests +{ + private static ForeignSrdoConfiguration Make(byte peer = 5, int number = 1, uint id1 = 0x101, uint id2 = 0x102, + IEnumerable? mapping = null, double srvt = 10, double sct = 100, string provenance = "DCF rev 3") + => new(peer, number, id1, id2, mapping ?? Array.Empty(), TimeSpan.FromMilliseconds(srvt), TimeSpan.FromMilliseconds(sct), provenance); + + [Fact] + public void Mapping_Is_Copied_And_Configuration_Metadata_Is_Immutable() + { + var entries = new[] { new PdoMappingEntry(0x2000, 1, 8) }; + var config = Make(mapping: entries); + entries[0] = new PdoMappingEntry(0x3000, 2, 16); + config.Mapping[0].Index.Should().Be(0x2000); config.Mapping[0].Subindex.Should().Be(1); + config.PeerNodeId.Should().Be(5); config.Number.Should().Be(1); config.CobId1.Should().Be(0x101); config.CobId2.Should().Be(0x102); + config.ValidationTime.Should().Be(TimeSpan.FromMilliseconds(10)); config.SafeguardCycleTime.Should().Be(TimeSpan.FromMilliseconds(100)); + config.Provenance.Should().Be("DCF rev 3"); config.SourceSnapshot.Should().BeNull(); config.ConfigurationId.Should().NotBeEmpty(); + ((IList)config.Mapping).Invoking(x => x.Clear()).Should().Throw(); + } + + [Theory] + [InlineData(0, 1, 0x101u, 0x102u, 10, 100, "ok")] + [InlineData(128, 1, 0x101u, 0x102u, 10, 100, "ok")] + [InlineData(1, 0, 0x101u, 0x102u, 10, 100, "ok")] + [InlineData(1, 65, 0x101u, 0x102u, 10, 100, "ok")] + [InlineData(1, 1, 0x102u, 0x103u, 10, 100, "ok")] + [InlineData(1, 1, 0x101u, 0x104u, 10, 100, "ok")] + [InlineData(1, 1, 0x101u, 0x102u, 0, 100, "ok")] + [InlineData(1, 1, 0x101u, 0x102u, 256, 100, "ok")] + [InlineData(1, 1, 0x101u, 0x102u, 10, 0, "ok")] + [InlineData(1, 1, 0x101u, 0x102u, 10, 65536, "ok")] + [InlineData(1, 1, 0x101u, 0x102u, 10, 100, " ")] + public void Invalid_Prepared_Inputs_Are_Explicit_Exceptions(byte peer, int number, uint id1, uint id2, int srvt, int sct, string provenance) + { + Action construct = () => Make(peer, number, id1, id2, srvt: srvt, sct: sct, provenance: provenance); + construct.Should().Throw(); + } + + [Fact] + public void Null_And_Invalid_Mapping_Are_Rejected_Before_Monitoring() + { + Action missing = () => new ForeignSrdoConfiguration(1, 1, 0x101, 0x102, null!, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2), "DCF"); + missing.Should().Throw(); + Action invalid = () => Make(mapping: new[] { default(PdoMappingEntry) }); + invalid.Should().Throw(); + } + + private static PeerSafetySnapshot Snapshot(SrdoDirection direction, bool eligible = true, bool parameterPresent = true) + { + var parameter = new SrdoCommunicationParameter(direction, TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(20), 0x101, 0x102); + var entry = new PeerSafetyEntry(0x1301, 3, new byte[] { 20 }, SafetyValueOrigin.CiA319Default); + return new PeerSafetySnapshot(5, new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero), TimeSpan.Zero, + new[] { entry }, Array.Empty(), + new[] { new PeerSafetySrdoSnapshot(1, parameterPresent ? parameter : null, + new[] { new PdoMappingEntry(0x2000, 1, 8) }, 0x1234, 0x1234, SafetyComparison.Match, eligible) }, + Array.Empty(), true, true, SafetyComparison.NotRequested, PeerSafetySnapshotConsistency.Unconfirmed); + } + + [Fact] + public void Producer_Snapshot_Requires_Both_Observer_Times_And_Preserves_Full_Provenance() + { + var snapshot = Snapshot(SrdoDirection.Transmit); + Action absent = () => ForeignSrdoConfiguration.FromSnapshot(snapshot, 1); + Action noSct = () => ForeignSrdoConfiguration.FromSnapshot(snapshot, 1, TimeSpan.FromMilliseconds(3)); + Action noSrvt = () => ForeignSrdoConfiguration.FromSnapshot(snapshot, 1, safeguardCycleTime: TimeSpan.FromMilliseconds(99)); + absent.Should().Throw(); noSct.Should().Throw(); noSrvt.Should().Throw(); + var config = ForeignSrdoConfiguration.FromSnapshot(snapshot, 1, TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(99)); + config.SourceSnapshot.Should().BeSameAs(snapshot); config.ConfigurationId.Should().Be(snapshot.ConfigurationId); + config.SourceSnapshot!.Entries[0].Origin.Should().Be(SafetyValueOrigin.CiA319Default); + config.ValidationTime.Should().Be(TimeSpan.FromMilliseconds(3)); config.SafeguardCycleTime.Should().Be(TimeSpan.FromMilliseconds(99)); + config.Mapping[0].Index.Should().Be(0x2000); + } + + [Fact] + public void Receiver_Snapshot_Uses_Consumer_Times_And_Rejects_Ineligible_Missing_Or_Null_Input() + { + var config = ForeignSrdoConfiguration.FromSnapshot(Snapshot(SrdoDirection.Receive), 1); + config.ValidationTime.Should().Be(TimeSpan.FromMilliseconds(20)); config.SafeguardCycleTime.Should().Be(TimeSpan.FromMilliseconds(250)); + Action ineligible = () => ForeignSrdoConfiguration.FromSnapshot(Snapshot(SrdoDirection.Receive, eligible: false), 1); + Action missingParameter = () => ForeignSrdoConfiguration.FromSnapshot(Snapshot(SrdoDirection.Receive, parameterPresent: false), 1); + Action missingRecord = () => ForeignSrdoConfiguration.FromSnapshot(Snapshot(SrdoDirection.Receive), 2); + Action missing = () => ForeignSrdoConfiguration.FromSnapshot(null!, 1); + ineligible.Should().Throw(); missingParameter.Should().Throw(); + missingRecord.Should().Throw(); missing.Should().Throw(); + } + + [Fact] + public void Invalid_And_Ambiguous_Monitor_Configuration_Is_Refused_Atomically() + { + using var service = new CanKit.Pro.Tests.Infrastructure.StarvedReaderBusService(); + var sub = service.Subscribe(); + Action missingSub = () => new ForeignSrdoMonitor(null!, "CAN", new[] { Make() }); + Action missingConfig = () => new ForeignSrdoMonitor(sub, "CAN", null!); + Action nullConfig = () => new ForeignSrdoMonitor(sub, "CAN", new ForeignSrdoConfiguration[] { null! }); + Action overlap = () => new ForeignSrdoMonitor(sub, "CAN", new[] { Make(), Make(6, 2) }); + Action duplicate = () => new ForeignSrdoMonitor(sub, "CAN", new[] { Make(), Make(id1: 0x103, id2: 0x104) }); + missingSub.Should().Throw(); missingConfig.Should().Throw(); + nullConfig.Should().Throw(); overlap.Should().Throw(); duplicate.Should().Throw(); + } +} diff --git a/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/ForeignSrdoMonitorTests.cs b/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/ForeignSrdoMonitorTests.cs new file mode 100644 index 00000000..28de8087 --- /dev/null +++ b/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/ForeignSrdoMonitorTests.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using CanKit.Abstractions.API.Can.Definitions; +using CanKit.Abstractions.API.Common.Definitions; +using CanKit.Pro.CANopen.Pdo; +using CanKit.Pro.CANopen.Safety; +using CanKit.Pro.RawCan; +using CanKit.Pro.Tests.Infrastructure; +using Xunit; + +namespace CanKit.Pro.Tests.TestCases.CANopen.Safety; + +/// FR-CO-061: passive capture, deterministic pairing/deadlines and explicit input quality. +public class ForeignSrdoMonitorTests : IClassFixture +{ + private static readonly TimeSpan Srvt = TimeSpan.FromMilliseconds(10); + private static readonly TimeSpan Sct = TimeSpan.FromMilliseconds(100); + private static ForeignSrdoConfiguration Config(byte peer = 5, int number = 1, uint id = 0x101, + TimeSpan? srvt = null, TimeSpan? sct = null, IEnumerable? mapping = null) + => new(peer, number, id, id + 1, mapping ?? new[] { new PdoMappingEntry(0x2000, 1, 16) }, + srvt ?? Srvt, sct ?? Sct, "test DCF revision 7"); + + private sealed class Capture : ISubscription + { + internal readonly Queue Queue = new(); + internal ulong Next = 1; + internal long Drops; + internal bool Disposed; + internal Action? OnRead; + internal void Add(uint id, byte[] payload, long time, FrameFlags flags = FrameFlags.None, + CanFrameType kind = CanFrameType.Can20, bool echo = false) + => Queue.Enqueue(new SubscriptionFrame(new CanFrameEvent(new CanFrameView(kind, (int)id, payload, flags), + echo, TimeSpan.FromTicks(time * 2), time), Next++)); + public bool TryRead(out CanFrameEvent frameEvent) + { + bool result = Read(out var envelope); + frameEvent = envelope.FrameEvent; + return result; + } + internal bool Read(out SubscriptionFrame frame) + { + OnRead?.Invoke(); + if (Queue.Count == 0) { frame = default; return false; } + frame = Queue.Dequeue(); return true; + } + public IAsyncEnumerable Frames => throw new NotSupportedException(); + public ValueTask WaitToReadAsync(CancellationToken cancellationToken = default) => new(Queue.Count > 0); + public void Reconfigure(CanIdFilter filter) => throw new NotSupportedException(); + public void Reconfigure(Func? predicate) => throw new NotSupportedException(); + public void Dispose() => Disposed = true; + } + + private sealed class OrderedCapture(Capture capture) : ILossAwareSubscription + { + public long DroppedFrameCount => capture.Drops; + public bool TryReadWithSequence(out SubscriptionFrame frame) => capture.Read(out frame); + public IAsyncEnumerable FramesWithSequence => throw new NotSupportedException(); + public bool TryRead(out CanFrameEvent frameEvent) => capture.TryRead(out frameEvent); + public IAsyncEnumerable Frames => capture.Frames; + public ValueTask WaitToReadAsync(CancellationToken cancellationToken = default) => capture.WaitToReadAsync(cancellationToken); + public void Reconfigure(CanIdFilter filter) => capture.Reconfigure(filter); + public void Reconfigure(Func? predicate) => capture.Reconfigure(predicate); + public void Dispose() => capture.Dispose(); + } + + private sealed class Rig : IDisposable + { + internal readonly ManualTimeSource Clock = new(); + internal readonly Capture Input = new(); + internal readonly ForeignSrdoMonitor Monitor; + internal Rig(bool ordered = true, params ForeignSrdoConfiguration[] configurations) + { + Clock.Advance(TimeSpan.FromSeconds(1)); + Monitor = new ForeignSrdoMonitor(ordered ? new OrderedCapture(Input) : Input, "CAN-A", + configurations.Length == 0 ? new[] { Config() } : configurations, true, Clock); + } + internal long At(double milliseconds) => TimeSpan.TicksPerSecond + TimeSpan.FromMilliseconds(milliseconds).Ticks; + internal void Until(double milliseconds) => Clock.Advance(TimeSpan.FromTicks(At(milliseconds) - Clock.GetTimestamp())); + internal void Add(uint id, double milliseconds, params byte[] bytes) => Input.Add(id, bytes, At(milliseconds)); + internal void Pair(double first = 1, double second = 2, uint id = 0x101) + { Add(id, first, 0x12, 0x34); Add(id + 1, second, 0xED, 0xCB); } + internal ForeignSrdoState State() => Monitor.Poll().States.Single(); + public void Dispose() => Monitor.Dispose(); + } + + [Fact] + public void Delayed_Poll_Uses_Capture_Time_And_Returns_Immutable_Values_And_Provenance() + { + using var rig = new Rig(); + var initial = rig.State(); + initial.Status.Should().Be(ForeignSrdoStatus.NotReceived); + initial.LastValidTimestamp.Should().BeNull(); initial.Age.Should().BeNull(); initial.Signals.Should().BeEmpty(); + rig.Pair(); rig.Until(50); + var batch = rig.Monitor.Poll(); + batch.Observations.Select(x => x.Status).Should().Equal(ForeignSrdoStatus.WaitingForInverted, ForeignSrdoStatus.Valid); + var valid = batch.States.Single(); + valid.IsCurrent.Should().BeTrue(); valid.Age.Should().Be(TimeSpan.FromMilliseconds(48)); + valid.AsOfTimestamp.Should().Be(rig.At(50)); + valid.Configuration.Provenance.Should().Be("test DCF revision 7"); + valid.Signals.Single().Value.Should().Equal(0x12, 0x34); + valid.Signals.Single().Index.Should().Be(0x2000); valid.Signals.Single().Subindex.Should().Be(1); + var item = batch.Observations.Last(); + item.BusIdentity.Should().Be("CAN-A"); item.HostTimestamp.Should().Be(rig.At(2)); + item.ReceiveTimestamp.Should().Be(TimeSpan.FromTicks(rig.At(2) * 2)); item.SequenceNumber.Should().Be(2); + item.TimingAvailable.Should().BeTrue(); item.Configuration.Should().BeSameAs(valid.Configuration); + item.CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Contiguous); + item.EvaluationPeriodId.Should().Be(valid.EvaluationPeriodId); + ((IList)valid.Signals.Single().Value).Invoking(x => x[0] = 0).Should().Throw(); + rig.Until(102); + var expired = rig.State(); + expired.Status.Should().Be(ForeignSrdoStatus.SafeguardCycleExpired); + expired.IsCurrent.Should().BeFalse(); expired.Signals.Should().BeEmpty(); expired.LastValidTimestamp.Should().Be(rig.At(2)); + valid.IsCurrent.Should().BeTrue("retained results are immutable historical snapshots"); + } + + [Fact] + public void Interleaved_Srdos_And_Buses_Have_Independent_Pairs_And_Deadlines() + { + using var a = new Rig(true, Config(), Config(7, 2, 0x103, sct: TimeSpan.FromMilliseconds(30))); + using var b = new Rig(); + a.Add(0x101, 1, 1, 2); a.Add(0x103, 2, 3, 4); a.Add(0x102, 3, 254, 253); a.Add(0x104, 4, 252, 251); + a.Until(35); b.Until(35); + var batch = a.Monitor.Poll(); + batch.States[0].IsCurrent.Should().BeTrue(); + batch.States[1].Status.Should().Be(ForeignSrdoStatus.SafeguardCycleExpired); + b.State().Status.Should().Be(ForeignSrdoStatus.NotReceived); + batch.Observations.Last().HostTimestamp.Should().Be(a.At(34)); + } + + [Fact] + public void Startup_Inverted_Repeated_Normal_Mismatch_Order_And_Recovery_Are_Explicit() + { + using var rig = new Rig(); + rig.Add(0x102, 1, 0); rig.Add(0x102, 2, 0); + rig.Add(0x101, 3, 1, 2); rig.Add(0x101, 12, 3, 4); rig.Add(0x102, 13, 252, 251); + rig.Add(0x101, 14, 1, 2); rig.Add(0x102, 15, 0, 0); rig.Add(0x102, 16, 0, 0); + rig.Pair(17, 18); rig.Until(18); + var batch = rig.Monitor.Poll(); + batch.Observations.Select(x => x.Status).Should().Equal(ForeignSrdoStatus.StartupInverted, ForeignSrdoStatus.StartupInverted, + ForeignSrdoStatus.WaitingForInverted, ForeignSrdoStatus.WaitingForInverted, ForeignSrdoStatus.Valid, + ForeignSrdoStatus.WaitingForInverted, ForeignSrdoStatus.Mismatch, ForeignSrdoStatus.OutOfOrder, + ForeignSrdoStatus.WaitingForInverted, ForeignSrdoStatus.Valid); + batch.Observations.Where(x => x.Status != ForeignSrdoStatus.Valid).Should().OnlyContain(x => x.Signals.Count == 0); + batch.States.Single().IsCurrent.Should().BeTrue(); + } + + [Theory] + [InlineData(9, ForeignSrdoStatus.Valid)] + [InlineData(10, ForeignSrdoStatus.OutOfOrder)] + [InlineData(11, ForeignSrdoStatus.OutOfOrder)] + public void Srvt_Boundary_Is_Capture_Based_And_Expired_Pending_Data_Is_Not_Used(int delay, ForeignSrdoStatus expected) + { + using var rig = new Rig(); + rig.Pair(1, 1 + delay); rig.Until(50); + var result = rig.Monitor.Poll(); + result.States.Single().Status.Should().Be(expected); + if (delay >= 10) + { + result.Observations.Should().Contain(x => x.Status == ForeignSrdoStatus.ValidationTimeExpired && x.HostTimestamp == rig.At(11)); + result.States.Single().Signals.Should().BeEmpty(); + } + } + + [Fact] + public void Sct_And_Srvt_Expire_Once_In_Chronological_Order_With_Sct_First_On_Tie() + { + using var rig = new Rig(true, Config(sct: TimeSpan.FromMilliseconds(11))); + rig.Add(0x101, 1, 1, 2); rig.Until(11); + rig.Monitor.Poll().Observations.Select(x => x.Status).Should().Equal(ForeignSrdoStatus.WaitingForInverted, ForeignSrdoStatus.SafeguardCycleExpired); + rig.Monitor.Poll().Observations.Should().BeEmpty(); + rig.Pair(12, 13); rig.Until(13); rig.State().IsCurrent.Should().BeTrue(); + rig.Until(24); rig.State().Status.Should().Be(ForeignSrdoStatus.SafeguardCycleExpired); + } + + [Fact] + public void Mismatch_And_Short_Pairs_Do_Not_Reset_Sct_Or_Keep_Old_Values() + { + using var rig = new Rig(); + rig.Pair(); rig.Until(2); rig.State().IsCurrent.Should().BeTrue(); + rig.Add(0x101, 90, 1); rig.Add(0x102, 91, 254); rig.Add(0x102, 92, 254); + rig.Add(0x101, 93, 1, 2); rig.Add(0x102, 94, 254); + rig.Until(102); + var batch = rig.Monitor.Poll(); + batch.Observations.Select(x => x.Status).Should().ContainInOrder(ForeignSrdoStatus.ShortPayload, ForeignSrdoStatus.OutOfOrder, + ForeignSrdoStatus.Mismatch, ForeignSrdoStatus.SafeguardCycleExpired); + batch.States.Single().Signals.Should().BeEmpty(); + } + + [Theory] + [InlineData(FrameFlags.Ext, CanFrameType.Can20, 2)] + [InlineData(FrameFlags.Rtr, CanFrameType.Can20, 2)] + [InlineData(FrameFlags.Error, CanFrameType.Can20, 2)] + [InlineData(FrameFlags.None, CanFrameType.CanFd, 2)] + [InlineData(FrameFlags.None, CanFrameType.Can20, 9)] + public void Invalid_Srdo_Format_Discards_Pending_And_Current(FrameFlags flags, CanFrameType kind, int length) + { + using var rig = new Rig(); + rig.Add(0x101, 1, 1, 2); + rig.Input.Add(0x102, new byte[length], rig.At(2), flags, kind); + rig.Until(2); + var state = rig.State(); + state.Status.Should().Be(ForeignSrdoStatus.InvalidFormat); state.Signals.Should().BeEmpty(); + } + + [Fact] + public void Extra_Bytes_Within_Classic_Limit_Are_Checked_But_Only_Mapped_Bytes_Are_Decoded() + { + using var rig = new Rig(); + rig.Add(0x101, 1, 1, 2, 3); rig.Add(0x102, 2, 254, 253, 252); rig.Until(2); + rig.State().Signals.Single().Value.Should().Equal(1, 2); + } + + [Theory] + [InlineData(FrameFlags.None, CanFrameType.Can20, 0, ForeignSrdoStatus.GfcObserved)] + [InlineData(FrameFlags.Ext, CanFrameType.Can20, 0, ForeignSrdoStatus.MalformedGfc)] + [InlineData(FrameFlags.Rtr, CanFrameType.Can20, 0, ForeignSrdoStatus.MalformedGfc)] + [InlineData(FrameFlags.Error, CanFrameType.Can20, 0, ForeignSrdoStatus.MalformedGfc)] + [InlineData(FrameFlags.None, CanFrameType.CanFd, 0, ForeignSrdoStatus.MalformedGfc)] + [InlineData(FrameFlags.None, CanFrameType.Can20, 1, ForeignSrdoStatus.MalformedGfc)] + public void Every_Gfc_Candidate_Is_Preserved_Without_Node_Attribution_Or_Srdo_Configuration( + FrameFlags flags, CanFrameType kind, int length, ForeignSrdoStatus expected) + { + var clock = new ManualTimeSource(); clock.Advance(TimeSpan.FromSeconds(1)); + var input = new Capture(); + using var monitor = new ForeignSrdoMonitor(new OrderedCapture(input), "CAN-B", Array.Empty(), true, clock); + input.Add(1, new byte[length], clock.GetTimestamp(), flags, kind, echo: true); + input.Add(1, new byte[length], 0, flags, kind); + var batch = monitor.Poll(); + batch.States.Should().BeEmpty(); batch.Observations.Should().HaveCount(2); + batch.Observations.Should().OnlyContain(x => x.Status == expected && x.Configuration == null && x.BusIdentity == "CAN-B"); + batch.Observations[0].TimingAvailable.Should().BeTrue(); batch.Observations[1].TimingAvailable.Should().BeFalse(); + batch.Observations[1].SequenceNumber.Should().Be(2); + } + + [Fact] + public void Subscription_Gap_Prevents_Cross_Gap_Pairs_And_Fresh_Pair_Recovers_Only_Current_Window() + { + using var rig = new Rig(); + rig.Add(0x101, 1, 1, 2); rig.Until(1); var before = rig.State(); + rig.Input.Next++; rig.Input.Drops++; + rig.Add(0x102, 2, 254, 253); rig.Pair(3, 4); rig.Until(4); + var batch = rig.Monitor.Poll(); + batch.Observations.Should().Contain(x => x.Status == ForeignSrdoStatus.CaptureGap); + batch.Observations.Should().NotContain(x => x.Status == ForeignSrdoStatus.Valid && x.HostTimestamp == rig.At(2)); + batch.CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Incomplete); batch.DroppedFrameCount.Should().Be(1); + var recovered = batch.States.Single(); + recovered.CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Contiguous); recovered.IsCurrent.Should().BeTrue(); + recovered.Configuration.Should().BeSameAs(before.Configuration); + recovered.EvaluationPeriodId.Should().NotBe(before.EvaluationPeriodId); + rig.Until(104); + rig.State().Status.Should().Be(ForeignSrdoStatus.SafeguardCycleExpired); + } + + [Fact] + public void Counter_Only_Or_Concurrent_Loss_Invalidates_Even_A_Reconstructed_Pair() + { + using var rig = new Rig(); + rig.Pair(); rig.Until(2); + int reads = 0; + rig.Input.OnRead = () => { if (++reads == 2) rig.Input.Drops++; }; + var gap = rig.Monitor.Poll(); + gap.States.Single().Status.Should().Be(ForeignSrdoStatus.CaptureGap); + gap.States.Single().Signals.Should().BeEmpty(); + rig.Input.OnRead = null; + rig.Input.Drops++; rig.Pair(3, 4); rig.Until(4); + rig.State().Status.Should().Be(ForeignSrdoStatus.CaptureGap); + rig.Pair(5, 6); rig.Until(6); rig.State().IsCurrent.Should().BeTrue(); + } + + [Fact] + public void First_Sequence_Gap_And_Saturated_Counter_Still_Expose_Loss() + { + using var rig = new Rig(); + rig.Input.Next = 4; rig.Input.Drops = long.MaxValue; + rig.Pair(); rig.Until(2); rig.Monitor.Poll().CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Incomplete); + rig.Add(0x101, 3, 1, 2); rig.Input.Next++; rig.Add(0x102, 4, 254, 253); rig.Until(4); + rig.State().IsCurrent.Should().BeFalse(); + } + + [Fact] + public void Sequence_Wrap_Is_Contiguous_And_Deadline_Order_Does_Not_Follow_Configuration_Order() + { + using var rig = new Rig(true, Config(sct: TimeSpan.FromMilliseconds(100)), + Config(6, 2, 0x103, sct: TimeSpan.FromMilliseconds(30)), Config(7, 3, 0x105, sct: TimeSpan.FromMilliseconds(150))); + rig.Input.Next = ulong.MaxValue; + rig.Pair(); rig.Until(2); + rig.Monitor.Poll().Observations.Count(x => x.Status == ForeignSrdoStatus.CaptureGap).Should().Be(1); + rig.Pair(3, 4); rig.Until(4); + rig.Monitor.Poll().Observations.Should().NotContain(x => x.Status == ForeignSrdoStatus.CaptureGap); + rig.Monitor.ReplaceConfigurations(new[] { Config(sct: TimeSpan.FromMilliseconds(100)), + Config(6, 2, 0x103, sct: TimeSpan.FromMilliseconds(30)), Config(7, 3, 0x105, sct: TimeSpan.FromMilliseconds(150)) }); + rig.Until(200); + var batch = rig.Monitor.Poll(); + batch.Observations.Select(x => x.Configuration!.PeerNodeId).Should().Equal(6, 5, 7); + batch.Observations.Select(x => x.HostTimestamp).Should().Equal(rig.At(34), rig.At(104), rig.At(154)); + batch.States[0].CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Contiguous); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(20)] + public void Missing_Late_And_Future_Timing_Invalidate_Then_Fresh_Captures_Recover(int stamp) + { + using var rig = new Rig(); + rig.Pair(); rig.Until(10); rig.State().IsCurrent.Should().BeTrue(); + rig.Input.Add(0x101, new byte[] { 1, 2 }, stamp == 0 ? 0 : rig.At(stamp)); + var state = rig.State(); state.Status.Should().Be(ForeignSrdoStatus.TimingUnavailable); state.Signals.Should().BeEmpty(); + rig.Pair(11, 12); rig.Until(12); rig.State().IsCurrent.Should().BeTrue(); + } + + [Fact] + public void Replacement_Is_Atomic_And_Discards_Previous_Configuration_And_Buffered_Data() + { + using var rig = new Rig(); + rig.Pair(); rig.Until(2); var old = rig.State(); + rig.Pair(3, 4); rig.Until(5); + var config = Config(mapping: new[] { new PdoMappingEntry(0x3000, 2, 8) }); + rig.Monitor.Invoking(x => x.ReplaceConfigurations(new[] { config, config })).Should().Throw(); + rig.Monitor.ReplaceConfigurations(new[] { config }); + var batch = rig.Monitor.Poll(); + batch.Observations.Should().OnlyContain(x => x.Status == ForeignSrdoStatus.BeforeEvaluationPeriod); + batch.States.Single().LastValidTimestamp.Should().BeNull(); + batch.States.Single().EvaluationPeriodId.Should().NotBe(old.EvaluationPeriodId); + rig.Pair(6, 7); rig.Until(7); + rig.State().Signals.Single().Index.Should().Be(0x3000); + } + + [Fact] + public void Full_Batches_Do_Not_Advance_Past_Buffered_Frames_Or_Advertise_Current_Values() + { + using var rig = new Rig(); + rig.Pair(); rig.Add(0x321, 3, 1); rig.Until(200); + var first = rig.Monitor.Poll(2); + first.HasBacklog.Should().BeTrue(); first.States.Single().IsCurrent.Should().BeFalse(); first.States.Single().Signals.Should().BeEmpty(); + first.States.Single().Status.Should().Be(ForeignSrdoStatus.Valid); + var end = rig.Monitor.Poll(2); + end.HasBacklog.Should().BeFalse(); end.States.Single().Status.Should().Be(ForeignSrdoStatus.SafeguardCycleExpired); + rig.Monitor.Invoking(x => x.Poll(0)).Should().Throw(); + } + + [Fact] + public void Legacy_Capture_Quality_Remains_Unknown_And_Ownership_And_Disposal_Are_Explicit() + { + using var rig = new Rig(false); + rig.Pair(); rig.Until(2); + var batch = rig.Monitor.Poll(); + batch.CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Unknown); batch.DroppedFrameCount.Should().BeNull(); + batch.States.Single().CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Unknown); + rig.Monitor.Dispose(); rig.Monitor.Dispose(); rig.Input.Disposed.Should().BeTrue(); + rig.Monitor.Invoking(x => x.Poll()).Should().Throw(); + rig.Monitor.Invoking(x => x.ReplaceConfigurations(Array.Empty())).Should().Throw(); + var borrowed = new Capture(); + new ForeignSrdoMonitor(borrowed, "CAN-C", Array.Empty()).Dispose(); + borrowed.Disposed.Should().BeFalse(); + } + + [Fact] + public void Open_Uses_Real_Demux_And_Sends_Nothing_Including_On_Gap_Expiry_And_Disposal() + { + using var bus = ControllableBus.Plain("passive-401-" + Guid.NewGuid().ToString("N")); + using var service = new CanBusService(bus); + using var monitor = ForeignSrdoMonitor.Open(service, "physical-CAN", new[] { Config() }, 2); + using var gfc = CanFrame.Classic(1, Array.Empty()); + using var ignored = CanFrame.Classic(0x700, new byte[] { 0 }); + using var ignoredLow = CanFrame.Classic(0, Array.Empty()); + bus.RaiseObserved(ignoredLow, false); + bus.RaiseObserved(ignored, false); + using var normal = CanFrame.Classic(0x101, new byte[] { 1, 2 }); + bus.RaiseObserved(normal, false); + monitor.Poll(); + bus.RaiseObserved(gfc, true); bus.RaiseObserved(gfc, false); bus.RaiseObserved(gfc, false); + var batch = monitor.Poll(); + batch.Observations.Count(x => x.Status == ForeignSrdoStatus.GfcObserved).Should().Be(2); + batch.DroppedFrameCount.Should().Be(1); batch.CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Incomplete); + monitor.Dispose(); service.SubscriptionCount.Should().Be(0); bus.TransmitCount.Should().Be(0); + Action invalid = () => ForeignSrdoMonitor.Open(service, "", new[] { Config() }); + invalid.Should().Throw(); service.SubscriptionCount.Should().Be(0); + Action noService = () => ForeignSrdoMonitor.Open(null!, "CAN", Array.Empty()); + noService.Should().Throw(); + } +} From ce3567304d57923f1aaf7b330863a0293d29ada6 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards Date: Mon, 26 Oct 2026 06:38:15 +0100 Subject: [PATCH 2/3] fix(canopen): treat capture recovery as a mid-pair join --- src/CanKit.Pro.CANopen/README.md | 4 +++ .../Safety/ForeignSrdoMonitor.cs | 5 ++-- .../CANopen/Safety/ForeignSrdoMonitorTests.cs | 30 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/CanKit.Pro.CANopen/README.md b/src/CanKit.Pro.CANopen/README.md index 0985d4b8..24c41f0e 100644 --- a/src/CanKit.Pro.CANopen/README.md +++ b/src/CanKit.Pro.CANopen/README.md @@ -1383,6 +1383,10 @@ reports `HasBacklog` conservatively even if the final input exactly filled it, w signals until a subsequent drain. Missing/zero, future, backward, or already-assessed late host timestamps produce `TimingUnavailable`, discard pending/current values and suspend timing until fresh input establishes a new baseline. This cannot measure unknown driver/adapter delays. +The new window also treats lone inverted frames as a mid-pair join (`StartupInverted`), after +either a gap or insufficient timing. It cannot assert a producer ordering fault when the normal +half may have been unobserved. Repeated inverted halves remain startup observations until a +pair is seen or a fresh window deadline expires; capture quality remains independently qualified. States have `AsOfTimestamp`; retained instances are historical. `Age` describes the last valid pair, including after a fault; `Signals` is empty unless the state is current. A fault never silently reuses the last process value. diff --git a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs index 6c2d8da2..0394ae55 100644 --- a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs +++ b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs @@ -197,7 +197,7 @@ private void Process(CanFrameEvent input, ulong? sequence, List x.Status == ForeignSrdoStatus.CaptureGap); batch.Observations.Should().NotContain(x => x.Status == ForeignSrdoStatus.Valid && x.HostTimestamp == rig.At(2)); + var joined = batch.Observations.Single(x => x.HostTimestamp == rig.At(2)); + joined.Status.Should().Be(ForeignSrdoStatus.StartupInverted); + joined.CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Incomplete); batch.CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Incomplete); batch.DroppedFrameCount.Should().Be(1); var recovered = batch.States.Single(); recovered.CaptureQuality.Should().Be(ForeignSrdoCaptureQuality.Contiguous); recovered.IsCurrent.Should().BeTrue(); @@ -313,6 +316,33 @@ public void Missing_Late_And_Future_Timing_Invalidate_Then_Fresh_Captures_Recove rig.Pair(11, 12); rig.Until(12); rig.State().IsCurrent.Should().BeTrue(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Joining_A_New_Window_After_Gap_Or_Timing_Fault_Does_Not_Accuse_The_Producer(bool timingFault) + { + using var rig = new Rig(); + rig.Pair(); rig.Until(2); var previous = rig.State(); + if (timingFault) + { + rig.Input.Add(0x101, new byte[] { 1, 2 }, 0); + rig.State().Status.Should().Be(ForeignSrdoStatus.TimingUnavailable); + } + else { rig.Input.Next++; rig.Input.Drops++; } + rig.Add(0x102, 3, 254, 253); rig.Add(0x102, 4, 254, 253); rig.Until(4); + var batch = rig.Monitor.Poll(); + batch.Observations.Where(x => x.HostTimestamp >= rig.At(3)).Should().OnlyContain(x => x.Status == ForeignSrdoStatus.StartupInverted); + var state = batch.States.Single(); + state.Status.Should().Be(ForeignSrdoStatus.StartupInverted); state.IsCurrent.Should().BeFalse(); state.Signals.Should().BeEmpty(); + state.EvaluationPeriodId.Should().NotBe(previous.EvaluationPeriodId); + state.CaptureQuality.Should().Be(timingFault ? ForeignSrdoCaptureQuality.Contiguous : ForeignSrdoCaptureQuality.Incomplete); + rig.Until(103); + rig.State().Status.Should().Be(ForeignSrdoStatus.SafeguardCycleExpired); + rig.Add(0x102, 104, 254, 253); rig.Until(104); + rig.State().Status.Should().Be(ForeignSrdoStatus.OutOfOrder); + rig.Pair(105, 106); rig.Until(106); rig.State().IsCurrent.Should().BeTrue(); + } + [Fact] public void Replacement_Is_Atomic_And_Discards_Previous_Configuration_And_Buffered_Data() { From 6a1741f4ee9ab98666d39e0f4b309fc223288df5 Mon Sep 17 00:00:00 2001 From: Dietmar Borgards Date: Mon, 26 Oct 2026 06:40:16 +0100 Subject: [PATCH 3/3] docs(canopen): clarify passive recovery and deadline diagnostics --- src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs | 4 ++-- src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs index 0394ae55..07cf41d1 100644 --- a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs +++ b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoMonitor.cs @@ -250,8 +250,8 @@ private void Process(CanFrameEvent input, ulong? sequence, List items) { - // Process the earliest deadline across all SRDOs; SCT wins an exact tie, as the - // existing consumer arms SCT first. Expiry at the boundary precedes a captured frame. + // Process the earliest deadline across all SRDOs; within one SRDO, SCT wins an exact + // tie as the existing consumer arms SCT first. Expiry precedes a frame at the boundary. while (true) { Runtime? next = null; diff --git a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs index 5603b5a8..b6e17a21 100644 --- a/src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs +++ b/src/CanKit.Pro.CANopen/Safety/ForeignSrdoObservation.cs @@ -14,7 +14,7 @@ public enum ForeignSrdoStatus WaitingForInverted, /// A lone inverted frame at startup was ignored, matching local consumer semantics. StartupInverted, - /// A lone inverted frame after startup or a fault. + /// An inverted frame without a pending normal after a pair, a protocol error or deadline expiry. OutOfOrder, /// Payload lengths or bitwise inversion differ. Mismatch,