diff --git a/docs/requirements/SRS-CanKit.Pro-en.md b/docs/requirements/SRS-CanKit.Pro-en.md index ee12d00e..53435b8c 100644 --- a/docs/requirements/SRS-CanKit.Pro-en.md +++ b/docs/requirements/SRS-CanKit.Pro-en.md @@ -359,6 +359,7 @@ As-is state (2026-10-09): shipped as `CanKit.Pro.CANopen`. The scope of the devi | FR-CO-057 | The system MUST send and receive SAM and DAM MPDOs (mapping count FEh/FFh), event-driven only, with at most one SAM producer. Scanner and dispatcher lists including sub-index blocks, addressed and broadcast DAM reception and EMCY 8230h on an unavailable DAM target are implemented. PDO observation delivers the transferred multiplexer and four data bytes. | Must | CanOpenPdoEngineTests.Destination_Mpdo_Addresses_Local_Objects_And_Reports_Unavailable_Ones, Source_Mpdo_Uses_Scanner_And_Dispatcher_And_Has_One_Producer; CanOpenForeignPdoObserveTests.Observe_Extended_Bit_Mapping_And_Mpdo_From_The_File. | CiA 301 §7.2.3, §7.5.2.39/40; user assignment PDO extensions | | 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) | #### 4.3.3 J1939 (application) diff --git a/docs/requirements/SRS-CanKit.Pro.md b/docs/requirements/SRS-CanKit.Pro.md index 67a5811d..df2acc42 100644 --- a/docs/requirements/SRS-CanKit.Pro.md +++ b/docs/requirements/SRS-CanKit.Pro.md @@ -357,6 +357,7 @@ Ist-Zustand (09.10.2026): ausgeliefert als `CanKit.Pro.CANopen`. Der Zuschnitt d | FR-CO-057 | Das System MUSS SAM- und DAM-MPDO (Mapping-Zähler FEh/FFh) senden und empfangen, nur ereignisgesteuert, mit höchstens einem SAM-Produzenten. Scanner- und Dispatcher-Listen einschließlich Subindex-Blöcken, adressierter und Broadcast-DAM-Empfang und EMCY 8230h bei nicht verfügbarem DAM-Ziel werden umgesetzt. Die PDO-Beobachtung liefert den übertragenen Multiplexer und vier Datenbytes. | Must | CanOpenPdoEngineTests.Destination_Mpdo_Addresses_Local_Objects_And_Reports_Unavailable_Ones, Source_Mpdo_Uses_Scanner_And_Dispatcher_And_Has_One_Producer; CanOpenForeignPdoObserveTests.Observe_Extended_Bit_Mapping_And_Mpdo_From_The_File. | CiA 301 §7.2.3, §7.5.2.39/40; Nutzerauftrag PDO-Erweiterungen | | 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) | #### 4.3.3 J1939 (Applikation) diff --git a/src/CanKit.Pro.CANopen/CanOpenNode.SafetyInspection.cs b/src/CanKit.Pro.CANopen/CanOpenNode.SafetyInspection.cs new file mode 100644 index 00000000..33770c5f --- /dev/null +++ b/src/CanKit.Pro.CANopen/CanOpenNode.SafetyInspection.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CanKit.Pro.CANopen.Safety; +using CanKit.Pro.CANopen.Sdo; + +namespace CanKit.Pro.CANopen; + +internal sealed partial class CanOpenNode : ICanOpenSafetyInspection +{ + public async Task ReadPeerSafetyConfigurationAsync(byte peerNodeId, + PeerSafetyConfiguration? expected = null, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + CanOpenCobId.ValidateNodeId(peerNodeId); + var channel = PeerSdoChannel(peerNodeId); + await channel.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var description = CaptureBoundPeerDescription(peerNodeId) ?? throw NoDescriptionForSafety(peerNodeId); + var expectation = expected?.Snapshot(); + if (expectation is not null) ValidateSafetyTimes(expectation, nameof(expected)); + var binding = new PeerDescriptionBinding(description, Array.Empty<(ushort, byte)>()); + if (expectation is not null) EnsureValidationTimesFitDescription(expectation, binding, nameof(expected)); + var started = DateTimeOffset.UtcNow; + var timer = Stopwatch.StartNew(); + var entries = new List(); + var rechecks = new List(); + var findings = new List(); + var requests = new List<(ushort Index, byte Subindex, int Width)>(); + bool recheckWidthsValid = true; + + PeerSafetyEntry Fallback(ushort index, byte sub, int width, SdoAbortException? abort) + { + // Only an explicitly declared file value, never a loader-created default image. + if (description.TryGet(index, out var described)) + { + var entry = described.Find(e => e.Subindex == sub); + if (ParseDescribedUnsigned(entry.Value, peerNodeId) is { } word && + (width == 4 || word < (1u << (8 * width)))) + return new PeerSafetyEntry(index, sub, ObjectDictionary.EncodeU32(word).Take(width).ToArray(), + SafetyValueOrigin.DeviceDescription, abort?.AbortCode, abort?.Origin); + } + return new PeerSafetyEntry(index, sub, Array.Empty(), SafetyValueOrigin.Unavailable, abort?.AbortCode, abort?.Origin); + } + + async Task Read(ushort index, byte sub, int width, bool recheck = false) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var raw = await SdoUploadCoreAsync(peerNodeId, index, sub, SdoTransferMode.Auto, + cancellationToken, transaction: binding).ConfigureAwait(false); + if (recheck && raw.Length != width) + { + findings.Add(new PeerSafetyFinding(index, sub, PeerSafetyFindingKind.InvalidValue, + $"Repeated upload expected {width} bytes, received {raw.Length}.")); + if (index > SrdoRecords.GfcParameter) recheckWidthsValid = false; + } + return new PeerSafetyEntry(index, sub, raw, SafetyValueOrigin.Live); + } + catch (SdoAbortException abort) + { + findings.Add(new PeerSafetyFinding(index, sub, PeerSafetyFindingKind.SdoAbort, abort.Message)); + if (SrdoRecords.IsCommunicationRecord(index) && sub == 3 && + abort.Origin == SdoAbortOrigin.Peer && abort.AbortCode == 0x06090011 && + binding.ValidationTimeDeclaration(index - SrdoRecords.CommunicationBase) == SrvtDeclaration.Undeclared && + entries.Any(e => e.Index == index && e.Subindex == 1 && e.Origin == SafetyValueOrigin.Live && e.UnsignedValue == 1)) + return new PeerSafetyEntry(index, sub, new[] { SrdoRecords.DefaultValidationTimeMs }, + SafetyValueOrigin.CiA319Default, abort.AbortCode, abort.Origin); + return Fallback(index, sub, width, abort); + } + catch (PeerSdoAccessException ex) + { + findings.Add(new PeerSafetyFinding(index, sub, PeerSafetyFindingKind.AccessRefused, ex.Message)); + return Fallback(index, sub, width, null); + } + catch (Exception ex) when (ex is CanOpenTransportException || ex is InvalidOperationException and not ObjectDisposedException) + { + findings.Add(new PeerSafetyFinding(index, sub, PeerSafetyFindingKind.Unavailable, ex.Message)); + return Fallback(index, sub, width, null); + } + } + + async Task Capture(ushort index, byte sub, int width) + { + requests.Add((index, sub, width)); + var entry = await Read(index, sub, width).ConfigureAwait(false); + entries.Add(entry); + return entry; + } + + for (byte sub = 1; sub <= 4; sub++) await Capture(Co.Identity, sub, 4).ConfigureAwait(false); + await Capture(SrdoRecords.GfcParameter, 0, 1).ConfigureAwait(false); + await Capture(SrdoRecords.ConfigurationValid, 0, 1).ConfigureAwait(false); + var count = await Capture(SrdoRecords.Checksum, 0, 1).ConfigureAwait(false); + int bound = binding.DescribedSrdoCount; + int peerCount = count.RawValue.Count == 1 ? count.RawValue[0] : -1; + bool countUsable = count.Origin == SafetyValueOrigin.Live && peerCount >= 0 && peerCount <= SrdoRecords.MaxSrdoCount; + if (countUsable && peerCount <= bound) bound = peerCount; + else findings.Add(new PeerSafetyFinding(SrdoRecords.Checksum, 0, PeerSafetyFindingKind.InvalidValue, + "The live SRDO count is unavailable, malformed, above 64 or above the bound description. Inspection stays bounded by the description.")); + bool mappingCountsComplete = true; + for (int n = 1; n <= bound; n++) + { + ushort comm = SrdoRecords.CommIndex(n), map = SrdoRecords.MapIndex(n); + for (byte sub = 0; sub <= 6; sub++) + await Capture(comm, sub, sub is 2 ? 2 : sub is 5 or 6 ? 4 : 1).ConfigureAwait(false); + var mappingCount = await Capture(map, 0, 1).ConfigureAwait(false); + // Retain every advertised slot, including malformed pairs. 128 is the bounded + // record range; interpretation below retains this implementation's 8-byte limit. + int slots = mappingCount.RawValue.Count == 1 ? mappingCount.RawValue[0] : -1; + if (slots >= 0 && slots <= 128) + for (byte sub = 1; sub <= slots; sub++) + await Capture(map, sub, 4).ConfigureAwait(false); + else mappingCountsComplete = false; + await Capture(SrdoRecords.Checksum, (byte)n, 2).ConfigureAwait(false); + } + foreach (var request in requests) + rechecks.Add(await Read(request.Index, request.Subindex, request.Width, recheck: true).ConfigureAwait(false)); + + var expectedValues = new Dictionary<(ushort, byte), byte[]>(); + if (expectation is not null) + { + foreach (var value in SafetyWrites(expectation, bound, binding, nameof(expected)) + .Where(w => w.Index != SrdoRecords.Checksum || expectation.Srdos.ContainsKey(w.Subindex))) + expectedValues[(value.Index, value.Subindex)] = value.Value; + expectedValues[(SrdoRecords.ConfigurationValid, 0)] = new[] { SrdoRecords.ConfigurationValidValue }; + foreach (var n in expectation.Srdos.Keys.Where(n => n > bound)) + findings.Add(new PeerSafetyFinding(SrdoRecords.CommIndex(n), 1, PeerSafetyFindingKind.ExpectedUnavailable, + "The expected SRDO lies outside the acquired peer/description range.")); + } + var mappedObjects = entries.Where(e => SrdoRecords.IsMappingRecord(e.Index) && + (e.Subindex & 1) != 0 && e.RawValue.Count == 4) + .Select(e => ((ushort)(e.UnsignedValue!.Value >> 16), (byte)(e.UnsignedValue.Value >> 8))); + var mappingBinding = new PeerDescriptionBinding(description, mappedObjects); + return PeerSafetyDiagnostics.Build(peerNodeId, started, timer.Elapsed, entries, rechecks, findings, + bound, recheckWidthsValid && mappingCountsComplete && countUsable && peerCount <= binding.DescribedSrdoCount, + expectation is not null, expectedValues, (entry, direction) => MappingEntryRefusal(mappingBinding, entry, direction), + (n, parameter) => ValidationTimeKind(binding, n, parameter.Direction) != SafetyTransfer.Mandatory && + parameter.ValidationTime != TimeSpan.FromMilliseconds(SrdoRecords.DefaultValidationTimeMs) + ? "The bound file declares no writable producer SRVT; the existing CiA 319 rule requires 20 ms. Raw values are retained without substitution." + : null); + } + finally { channel.Release(); } + } +} diff --git a/src/CanKit.Pro.CANopen/README.md b/src/CanKit.Pro.CANopen/README.md index e54a717a..066fdf71 100644 --- a/src/CanKit.Pro.CANopen/README.md +++ b/src/CanKit.Pro.CANopen/README.md @@ -1271,6 +1271,67 @@ transaction, a call in flight — makes every live read of that observation "alr so the whole snapshot is the file's; the live COB-ID of one configuration is never combined with the file's mapping of another. +### Read-only peer safety inspection + +`node.Safety().ReadPeerSafetyConfigurationAsync(peer, expected: null, token)` (FR-CO-060) +acquires a tool-side `PeerSafetySnapshot` without a target DCF. Bind a peer EDS/DCF first with +`BindPeerDeviceDescription`: it still governs every SDO upload. This optional operation uses +`ICanOpenSafetyInspection`; existing implementations of `ICanOpenSafety` need no new members. +The extension reports `NotSupportedException` for implementations without inspection support. + +```csharp +node.BindPeerDeviceDescription(peer, peerEds); +var image = await node.Safety().ReadPeerSafetyConfigurationAsync(peer, cancellationToken: token); +foreach (var srdo in image.Srdos) + Console.WriteLine($"SRDO {srdo.Number}: CRC {srdo.SignatureComparison}, usable input {srdo.EligibleForMonitoring}"); +foreach (var finding in image.Findings) + Console.WriteLine($"{finding.Index:X4}:{finding.Subindex:X2} {finding.Kind}: {finding.Message}"); +``` + +The operation reserves this client's peer-SDO channel, then copies the binding and optional +expectation. It reads identity `1018h:01..04`, `1300h`, `13FEh`, `13FFh` and the peer's described +communication/mapping records. The live count cannot expand the scan beyond the description +or 64 SRDOs. Each mapping's advertised slots are retained up to the bounded 128-slot record +range, through the existing access gate; interpretation retains this implementation's fixed, +byte-aligned, 8-byte mapping limit. Missing entries, aborts (with code and origin), wrong widths, +unsupported mappings and optional expected-value failures are structured per-entry findings. +It sends uploads only, no downloads, NMT, store/commit, SRDO or GFC commands, and never changes +the peer's state to make an entry readable. Cancellation can send the SDO client's normal abort; +cancellation and node disposal propagate rather than returning a completed partial result. + +`Entries` retains owned raw bytes and provenance; `RecheckEntries` retains the repeated uploads. +An explicitly declared ParameterValue (otherwise DefaultValue) is retained as `DeviceDescription` +when a live read fails, never as live evidence. Such fallback values cannot complete a live +image or satisfy its signature/expected comparison. An undeclared producer SRVT answered by +the peer with `0609 0011h` is represented explicitly as `CiA319Default` (20 ms), with the abort +retained, following the existing CiA 319 rule. A local timeout or another abort does not establish +that rule. A live producer SRVT outside the existing non-writable-parameter rule is retained +unchanged and diagnosed, rather than silently replaced. CRC serialization and mapped-object +validation reuse the configuration/verification implementation. The unresolved version and +byte-order interoperability questions in [#289](https://github.com/dborgards/CanKit.Pro/issues/289) +remain visible in `Findings`. + +`DeviceConfigurationValid` is only the live `13FEh` flag. Each `SignatureComparison` compares +the live `13FFh:n` with an independently recomputed signature, using live fields and the explicit +SRVT rule only. Deleted records' stale signatures are not assessed. Their inactive timing/COB-ID values do not +produce active communication errors; raw values and unavailable reads remain visible. `ExpectedComparison` is +separate and only requested when a configuration was supplied. A complete acquisition without +observed changes is required for `Match`; a known difference can still report `Mismatch` in a +partial image. A true flag cannot replace either +comparison. `IsComplete` describes the bounded acquisition and successful required rechecks; +it does not imply valid parameters. A mapping must also pass the existing declared-object, +fixed-width, access-direction and COB-ID uniqueness checks to be eligible for monitoring. + +Holding this client's channel prevents local interleaving, not device changes or other tools' +writes. The operation repeats every captured entry: different live/SRVT-rule evidence yields `ChangeObserved` +and disqualify pending monitoring input. Identical answers still mean `Unconfirmed`, never an +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. + ### What this is not This library is not developed to IEC 61508 / DIN V VDE 0801 and claims no safety integrity level. diff --git a/src/CanKit.Pro.CANopen/Safety/CanOpenSafetyExtensions.cs b/src/CanKit.Pro.CANopen/Safety/CanOpenSafetyExtensions.cs index 5eeae9fd..2c3f0497 100644 --- a/src/CanKit.Pro.CANopen/Safety/CanOpenSafetyExtensions.cs +++ b/src/CanKit.Pro.CANopen/Safety/CanOpenSafetyExtensions.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; namespace CanKit.Pro.CANopen.Safety; @@ -15,4 +17,15 @@ public static ICanOpenSafety Safety(this ICanOpenNode node) return node as ICanOpenSafety ?? throw new NotSupportedException("CANopen Safety is available on the nodes CanOpen.OpenNode creates."); } + /// Reads a peer's safety configuration through the optional inspection surface (#403). + /// Does not add a member requirement to existing ICanOpenSafety implementations. + /// The safety implementation does not support inspection. + public static Task ReadPeerSafetyConfigurationAsync(this ICanOpenSafety safety, + byte peerNodeId, PeerSafetyConfiguration? expected = null, CancellationToken cancellationToken = default) + { + if (safety is null) throw new ArgumentNullException(nameof(safety)); + return (safety as ICanOpenSafetyInspection + ?? throw new NotSupportedException("This safety implementation does not support read-only inspection.")) + .ReadPeerSafetyConfigurationAsync(peerNodeId, expected, cancellationToken); + } } diff --git a/src/CanKit.Pro.CANopen/Safety/ICanOpenSafetyInspection.cs b/src/CanKit.Pro.CANopen/Safety/ICanOpenSafetyInspection.cs new file mode 100644 index 00000000..46811ffb --- /dev/null +++ b/src/CanKit.Pro.CANopen/Safety/ICanOpenSafetyInspection.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace CanKit.Pro.CANopen.Safety; + +/// Optional tool-side read-only inspection without widening published ICanOpenSafety. +public interface ICanOpenSafetyInspection +{ + /// Uploads a bounded safety configuration image and repeats its entries to detect observed + /// changes. Requires a bound peer EDS/DCF for access validation; an expected target DCF/configuration + /// is optional. Holds this client's peer-SDO channel for the whole acquisition, snapshots the bound + /// description and expectation once that channel is held, and sends no downloads or state commands. + /// Read failures become entry findings and explicit file/default provenance where available. + /// Cancellation/disposal propagate rather than returning a completed partial acquisition. Repeated + /// identical reads cannot establish atomic consistency: another tool/device task may still edit the + /// peer. Unknown/unsupported entries are reported without changing its state. No safety approval. + /// No peer description is bound; refused before any frame. + Task ReadPeerSafetyConfigurationAsync(byte peerNodeId, + PeerSafetyConfiguration? expected = null, CancellationToken cancellationToken = default); +} diff --git a/src/CanKit.Pro.CANopen/Safety/PeerSafetyDiagnostics.cs b/src/CanKit.Pro.CANopen/Safety/PeerSafetyDiagnostics.cs new file mode 100644 index 00000000..41aa60ea --- /dev/null +++ b/src/CanKit.Pro.CANopen/Safety/PeerSafetyDiagnostics.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CanKit.Pro.CANopen.Pdo; + +namespace CanKit.Pro.CANopen.Safety; + +/// Pure interpretation of captured bytes. Never manufactures a live image from fallback values. +internal static class PeerSafetyDiagnostics +{ + internal static PeerSafetySnapshot Build(byte peer, DateTimeOffset started, TimeSpan duration, + IReadOnlyList entries, IReadOnlyList rechecks, + List findings, int count, bool countComplete, bool hasExpected, + IReadOnlyDictionary<(ushort, byte), byte[]> expected, + Func? mappingRefusal = null, + Func? parameterRefusal = null) + { + var values = entries.ToDictionary(e => (e.Index, e.Subindex)); + var again = rechecks.ToDictionary(e => (e.Index, e.Subindex)); + void Find(ushort index, byte sub, PeerSafetyFindingKind kind, string message) + => findings.Add(new PeerSafetyFinding(index, sub, kind, message)); + bool fieldsComplete = true; + uint? Read(ushort index, byte sub, int width, bool allowSrvtRule = false) + { + if (values.TryGetValue((index, sub), out var e) && + (e.Origin == SafetyValueOrigin.Live || allowSrvtRule && e.Origin == SafetyValueOrigin.CiA319Default)) + { + if (e.RawValue.Count == width) return e.UnsignedValue; + Find(index, sub, PeerSafetyFindingKind.InvalidValue, $"Expected {width} bytes, received {e.RawValue.Count}."); + } + else Find(index, sub, PeerSafetyFindingKind.Unavailable, "No live value; a file/default value is not device evidence."); + fieldsComplete = false; + return null; + } + bool Evidence(PeerSafetyEntry e) => e.Origin is SafetyValueOrigin.Live or SafetyValueOrigin.CiA319Default; + bool Required(PeerSafetyEntry e) => e.Index >= SrdoRecords.CommunicationBase; + var consistency = PeerSafetySnapshotConsistency.Unconfirmed; + foreach (var e in entries.Where(e => again.TryGetValue((e.Index, e.Subindex), out var later) && + Evidence(e) && Evidence(later) && + (e.Origin != later.Origin || !e.RawValue.SequenceEqual(later.RawValue)))) + { + consistency = PeerSafetySnapshotConsistency.ChangeObserved; + Find(e.Index, e.Subindex, PeerSafetyFindingKind.ConfigurationChanged, "The repeated live/SRVT-rule evidence differs; start a new acquisition before using this configuration."); + } + // A missing optional GFC or identity does not make an SRDO mapping incomplete. It still + // has an entry-level finding and cannot satisfy an expectation requiring that value. + bool complete = countComplete && entries.Where(e => Required(e) && e.Index != SrdoRecords.GfcParameter) + .All(e => Evidence(e) && again.TryGetValue((e.Index, e.Subindex), out var later) && Evidence(later)); + uint? flag = Read(SrdoRecords.ConfigurationValid, 0, 1); + bool? valid = flag switch { SrdoRecords.ConfigurationValidValue => true, 0 => false, _ => null }; + if (flag.HasValue && valid is null) + Find(SrdoRecords.ConfigurationValid, 0, PeerSafetyFindingKind.InvalidValue, "13FEh is neither 00h nor A5h."); + var result = new List(); + var usedIds = new Dictionary(); + var collisions = new HashSet(); + for (int n = 1; n <= count; n++) + { + ushort comm = SrdoRecords.CommIndex(n), map = SrdoRecords.MapIndex(n); + var highest = Read(comm, 0, 1); + var direction = Read(comm, 1, 1); + var cycle = Read(comm, 2, 2); + var srvt = Read(comm, 3, 1, allowSrvtRule: true); + var transmission = Read(comm, 4, 1); + var cob1 = Read(comm, 5, 4); + var cob2 = Read(comm, 6, 4); + var slotsCount = Read(map, 0, 1); + var stored = Read(SrdoRecords.Checksum, (byte)n, 2); + var slots = new List<(byte Subindex, uint Value)>(); + bool rawMapping = slotsCount is <= 128; + if (rawMapping) + for (byte sub = 1; sub <= slotsCount; sub++) + { + if (Read(map, sub, 4) is { } word) slots.Add((sub, word)); + else rawMapping = false; + } + bool rawCommunication = direction.HasValue && cycle.HasValue && srvt.HasValue && cob1.HasValue && cob2.HasValue; + ushort? computed = rawCommunication && rawMapping + ? SrdoCrc.Crc16Xmodem(SrdoCrc.CanonicalBytes((byte)direction!.Value, (ushort)cycle!.Value, + (byte)srvt!.Value, cob1!.Value, cob2!.Value, (byte)slotsCount!.Value, slots)) + : null; + var comparison = stored.HasValue && computed.HasValue + ? stored.Value == computed.Value ? SafetyComparison.Match : SafetyComparison.Mismatch + : SafetyComparison.Unavailable; + // Deleted SRDO checksums are not verified by the existing device/verification path. + if (direction == 0) comparison = SafetyComparison.NotRequested; + if (comparison == SafetyComparison.Mismatch) + Find(SrdoRecords.Checksum, (byte)n, PeerSafetyFindingKind.SignatureMismatch, "Stored signature differs from the independently recomputed signature (implementation rules; #289 remains open)."); + // Like the existing verification path, a deleted record is identified by direction + // alone; its stale timing/COB-ID values are not active communication parameters. + bool communicationValid = highest == 6 && direction is 0 or 1 or 2 && + (direction == 0 || transmission == SrdoRecords.TransmissionType && cycle is > 0 && srvt is > 0 && + cob1.HasValue && cob2.HasValue && SrdoFrames.IsCobIdPair(cob1.Value, cob2.Value)); + if (!communicationValid) + Find(comm, 0, PeerSafetyFindingKind.InvalidValue, "Communication record is incomplete or outside this implementation's direction, timing, transmission-type or COB-ID rules."); + var mapping = new SrdoMapping(); + bool mappingValid = rawMapping && slotsCount is > 0 and <= SrdoRecords.MappingSubindices && (slotsCount.Value & 1) == 0; + if (mappingValid) + { + try + { + for (int i = 0; i < slots.Count; i += 2) + { + uint word = slots[i].Value; + if (!SrdoRecords.IsInvertedSlotOf(word, slots[i + 1].Value)) + { mappingValid = false; break; } + mapping.Add(new PdoMappingEntry((ushort)(word >> 16), (byte)(word >> 8), (byte)word)); + } + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { mappingValid = false; } + } + if (mappingValid && direction is 1 or 2 && mappingRefusal is not null) + for (int i = 0; i < mapping.Entries.Count; i++) + { + var entry = mapping.Entries[i]; + if (mappingRefusal(entry, (SrdoDirection)direction.Value) is { } refusal) + { + mappingValid = false; + Find(map, (byte)(2 * i + 1), PeerSafetyFindingKind.InvalidMapping, + $"Mapped object {entry.Index:X4}h:{entry.Subindex:X2} {refusal} in the bound description."); + } + } + if (!mappingValid && direction != 0) + Find(map, 0, PeerSafetyFindingKind.InvalidMapping, "Mapping is incomplete, has invalid plain/inverted slots or exceeds this implementation's byte-aligned 8-byte mapping limits."); + SrdoCommunicationParameter? parameter = communicationValid && rawCommunication + ? new SrdoCommunicationParameter((SrdoDirection)direction!.Value, TimeSpan.FromMilliseconds(cycle!.Value), + TimeSpan.FromMilliseconds(srvt!.Value), cob1!.Value, cob2!.Value) + : null; + if (parameter.HasValue && parameterRefusal?.Invoke(n, parameter.Value) is { } parameterReason) + { + communicationValid = false; + Find(comm, 3, PeerSafetyFindingKind.InvalidValue, parameterReason); + } + if (direction is 1 or 2 && cob1.HasValue && cob2.HasValue) + foreach (uint id in new[] { cob1.Value, cob2.Value }) + { + if (usedIds.TryGetValue(id, out int other)) + { + collisions.Add(n); collisions.Add(other); + Find(comm, 5, PeerSafetyFindingKind.InvalidValue, $"COB-ID {id:X} is also used by SRDO {other}."); + } + else usedIds.Add(id, n); + } + result.Add(new PeerSafetySrdoSnapshot(n, parameter, + mappingValid && direction != 0 ? mapping.ToArray() : Array.Empty(), + stored.HasValue ? (ushort)stored.Value : null, computed, comparison, + direction is 1 or 2 && communicationValid && mappingValid && comparison == SafetyComparison.Match && + valid == true && complete && consistency == PeerSafetySnapshotConsistency.Unconfirmed)); + } + complete &= fieldsComplete; + // A later collision or missing field invalidates the earlier record too. + result = result.Select(s => !complete || collisions.Contains(s.Number) + ? new PeerSafetySrdoSnapshot(s.Number, s.Parameter, s.Mapping.ToArray(), s.StoredSignature, + s.ComputedSignature, s.SignatureComparison, false) : s).ToList(); + var expectedComparison = hasExpected ? SafetyComparison.Match : SafetyComparison.NotRequested; + foreach (var pair in expected) + { + if (!values.TryGetValue(pair.Key, out var e) || !Evidence(e)) + { + if (expectedComparison != SafetyComparison.Mismatch) expectedComparison = SafetyComparison.Unavailable; + Find(pair.Key.Item1, pair.Key.Item2, PeerSafetyFindingKind.ExpectedUnavailable, "The expected entry has no live/explicit SRVT-rule evidence."); + } + else if (!e.RawValue.SequenceEqual(pair.Value)) + { + expectedComparison = SafetyComparison.Mismatch; + Find(e.Index, e.Subindex, PeerSafetyFindingKind.ExpectedMismatch, "The captured value differs from the supplied expectation."); + } + } + if (hasExpected && expectedComparison != SafetyComparison.Mismatch && findings.Any(f => f.Kind == PeerSafetyFindingKind.ExpectedUnavailable)) + expectedComparison = SafetyComparison.Unavailable; + if (hasExpected && expectedComparison == SafetyComparison.Match && + (!complete || consistency == PeerSafetySnapshotConsistency.ChangeObserved)) + { + expectedComparison = SafetyComparison.Unavailable; + Find(0, 0, PeerSafetyFindingKind.ExpectedUnavailable, + "A complete acquisition without observed changes is required to establish a matching expected configuration."); + } + findings.Add(new PeerSafetyFinding(0, 0, PeerSafetyFindingKind.NormativeUncertainty, + "CRC byte order and version-specific interoperability remain subject to #289. This diagnostic is not a safety approval.")); + return new PeerSafetySnapshot(peer, started, duration, entries, rechecks, result, findings, + valid, complete, expectedComparison, consistency); + } +} diff --git a/src/CanKit.Pro.CANopen/Safety/PeerSafetySnapshot.cs b/src/CanKit.Pro.CANopen/Safety/PeerSafetySnapshot.cs new file mode 100644 index 00000000..27166d4f --- /dev/null +++ b/src/CanKit.Pro.CANopen/Safety/PeerSafetySnapshot.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CanKit.Pro.CANopen.Pdo; +using CanKit.Pro.CANopen.Sdo; + +namespace CanKit.Pro.CANopen.Safety; + +/// Where a captured value came from; file values never stand in for live evidence. +public enum SafetyValueOrigin +{ + /// No usable value was obtained. + Unavailable, + /// Bytes returned by this acquisition's SDO upload. + Live, + /// Bound file's ParameterValue, or its DefaultValue where absent. + DeviceDescription, + /// Explicit CiA 319 SRVT rule for an unimplemented producer parameter. + CiA319Default, +} + +/// A comparison, separate from the device's validity flag and from safety approval. +public enum SafetyComparison +{ + /// No expectation was supplied. + NotRequested, + /// Required evidence was unavailable. + Unavailable, + /// The available values match under the documented implementation rules. + Match, + /// At least one available value differs. + Mismatch, +} + +/// Sequential uploads cannot establish an atomic device image. +public enum PeerSafetySnapshotConsistency +{ + /// No change was demonstrated; atomic consistency remains unconfirmed. + Unconfirmed, + /// Live or explicit SRVT-rule evidence differed when read again. + ChangeObserved, +} + +/// Machine-readable diagnostic category (#403). +public enum PeerSafetyFindingKind +{ + /// An SDO upload ended in an abort; the entry retains code and origin. + SdoAbort, + /// The local peer-description gate refused an entry. + AccessRefused, + /// A transport/state refusal made a value unavailable. + Unavailable, + /// Wrong width, count, direction, timing or communication value. + InvalidValue, + /// The mapping cannot be interpreted by this implementation. + InvalidMapping, + /// The recomputed signature differs from the live stored signature. + SignatureMismatch, + /// A live value differs from an optional expectation. + ExpectedMismatch, + /// An optional expectation could not be compared. + ExpectedUnavailable, + /// Repeated live uploads differ. + ConfigurationChanged, + /// An interoperability/normative choice remains open in #289. + NormativeUncertainty, +} + +/// One immutable diagnostic, located at an object entry. +public sealed class PeerSafetyFinding +{ + internal PeerSafetyFinding(ushort index, byte subindex, PeerSafetyFindingKind kind, string message) + { Index = index; Subindex = subindex; Kind = kind; Message = message; } + /// Object index; zero for an acquisition-wide finding. + public ushort Index { get; } + /// Object sub-index. + public byte Subindex { get; } + /// Diagnostic category. + public PeerSafetyFindingKind Kind { get; } + /// Diagnostic explanation; not a safety approval. + public string Message { get; } +} + +/// Immutable raw bytes and provenance of one attempted upload. +public sealed class PeerSafetyEntry +{ + internal PeerSafetyEntry(ushort index, byte subindex, byte[] value, SafetyValueOrigin origin, + uint? abortCode = null, SdoAbortOrigin? abortOrigin = null) + { + Index = index; Subindex = subindex; Origin = origin; + RawValue = Array.AsReadOnly((byte[])value.Clone()); + AbortCode = abortCode; AbortOrigin = abortOrigin; + } + /// Object index. + public ushort Index { get; } + /// Object sub-index. + public byte Subindex { get; } + /// Owned, read-only bytes; empty when unavailable. + public IReadOnlyList RawValue { get; } + /// Live, file/default, explicitly derived, or unavailable. + public SafetyValueOrigin Origin { get; } + /// SDO abort code, retained even when a file/default value is available. + public uint? AbortCode { get; } + /// Side that originated the abort. + public SdoAbortOrigin? AbortOrigin { get; } + /// Little-endian unsigned value for a 1-, 2- or 4-byte entry; otherwise null. + public uint? UnsignedValue => RawValue.Count switch + { + 1 => RawValue[0], + 2 => (uint)(RawValue[0] | RawValue[1] << 8), + 4 => (uint)(RawValue[0] | RawValue[1] << 8 | RawValue[2] << 16 | RawValue[3] << 24), + _ => null, + }; +} + +/// Immutable interpreted SRDO records and independent comparison outcomes. +public sealed class PeerSafetySrdoSnapshot +{ + internal PeerSafetySrdoSnapshot(int number, SrdoCommunicationParameter? parameter, PdoMappingEntry[] mapping, + ushort? stored, ushort? computed, SafetyComparison signature, bool eligible) + { + Number = number; Parameter = parameter; Mapping = Array.AsReadOnly((PdoMappingEntry[])mapping.Clone()); + StoredSignature = stored; ComputedSignature = computed; SignatureComparison = signature; EligibleForMonitoring = eligible; + } + /// SRDO number, 1..64. + public int Number { get; } + /// Structurally interpreted communication values from live/explicit SRVT-rule evidence; findings determine usability. + public SrdoCommunicationParameter? Parameter { get; } + /// Validated plain mapping entries; empty for an invalid or deleted record. + public IReadOnlyList Mapping { get; } + /// Live 13FFh:n, when available with its required width. + public ushort? StoredSignature { get; } + /// Independently computed signature over live fields (and explicit CiA 319 SRVT rule only). + public ushort? ComputedSignature { get; } + /// Stored versus recomputed signature; separate from 13FEh and expected configuration. + public SafetyComparison SignatureComparison { get; } + /// Usable mapping/timing, matching signature, live valid flag and successful unchanged rechecks. + /// Consistency is still unconfirmed; this is diagnostic input eligibility, never safety approval. + public bool EligibleForMonitoring { get; } +} + +/// One immutable, read-only acquisition of a peer's safety communication configuration (#403). +/// Each acquisition has a new identity. Replacing a running monitor's snapshot must start +/// a new evaluation period. No snapshot is an atomic device image or a safety approval. #289's +/// unresolved byte-order/version questions also apply to the signature comparisons here. +public sealed class PeerSafetySnapshot +{ + internal PeerSafetySnapshot(byte peer, DateTimeOffset started, TimeSpan duration, + IEnumerable entries, IEnumerable rechecks, + IEnumerable srdos, IEnumerable findings, + bool? valid, bool complete, SafetyComparison expected, PeerSafetySnapshotConsistency consistency) + { + ConfigurationId = Guid.NewGuid(); PeerNodeId = peer; StartedAtUtc = started; Duration = duration; + Entries = Array.AsReadOnly(entries.ToArray()); RecheckEntries = Array.AsReadOnly(rechecks.ToArray()); + Srdos = Array.AsReadOnly(srdos.ToArray()); Findings = Array.AsReadOnly(findings.ToArray()); + DeviceConfigurationValid = valid; IsComplete = complete; ExpectedComparison = expected; Consistency = consistency; + } + /// This acquisition's identity; not a device-provided version. + public Guid ConfigurationId { get; } + /// Peer node-id. + public byte PeerNodeId { get; } + /// UTC wall time at acquisition start, after obtaining the client's peer-SDO channel. + public DateTimeOffset StartedAtUtc { get; } + /// Monotonic elapsed acquisition duration. + public TimeSpan Duration { get; } + /// First uploads, including 1018h identity entries where readable; every raw value has provenance. + public IReadOnlyList Entries { get; } + /// Second uploads of the same bounded entry set, retained for observed-change diagnostics. + public IReadOnlyList RecheckEntries { get; } + /// Interpreted records, including deleted/unusable ones. + public IReadOnlyList Srdos { get; } + /// Per-entry findings, including unavailable values and interoperability limitations. + public IReadOnlyList Findings { get; } + /// Live 13FEh: A5h is true, 00h false, unavailable or another value null. + public bool? DeviceConfigurationValid { get; } + /// The bounded safety image and rechecks were read from live/explicit SRVT-rule evidence. + /// Does not imply valid parameters, atomic consistency, or safety approval. + public bool IsComplete { get; } + /// Optional expected configuration comparison, independent of signature and device flag. Match requires + /// a complete acquisition without observed changes; a known difference still reports Mismatch in a partial image. + public SafetyComparison ExpectedComparison { get; } + /// Observed change or unconfirmed consistency; never atomic consistency. + public PeerSafetySnapshotConsistency Consistency { 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 9726054e..66fe6a3b 100644 --- a/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.CANopen.approved.txt +++ b/tests/CanKit.Pro.Tests/ApiApprovals/CanKit.Pro.CANopen.approved.txt @@ -874,6 +874,7 @@ namespace CanKit.Pro.CANopen.Safety { public static class CanOpenSafetyExtensions { + 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 sealed class ForeignSrdoObserveResult @@ -904,6 +905,10 @@ namespace CanKit.Pro.CANopen.Safety System.Threading.Tasks.Task TriggerSrdoAsync(int srdoNumber, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task VerifyPeerSafetyConfigurationAsync(byte peerNodeId, CanKit.Pro.CANopen.Safety.PeerSafetyConfiguration expected, System.Threading.CancellationToken cancellationToken = default); } + public interface ICanOpenSafetyInspection + { + System.Threading.Tasks.Task ReadPeerSafetyConfigurationAsync(byte peerNodeId, CanKit.Pro.CANopen.Safety.PeerSafetyConfiguration? expected = null, System.Threading.CancellationToken cancellationToken = default); + } public sealed class PeerSafetyConfiguration { public PeerSafetyConfiguration() { } @@ -917,6 +922,36 @@ namespace CanKit.Pro.CANopen.Safety public CanKit.Pro.CANopen.Safety.PeerSafetyConfiguration Add(int srdoNumber, CanKit.Pro.CANopen.Safety.SrdoCommunicationParameter parameter, CanKit.Pro.CANopen.Safety.SrdoMapping mapping) { } public static CanKit.Pro.CANopen.Safety.PeerSafetyConfiguration FromDeviceDescription(CanKit.Pro.CANopen.CanOpenDeviceDescription description, byte nodeId) { } } + public sealed class PeerSafetyEntry + { + public uint? AbortCode { get; } + public CanKit.Pro.CANopen.Sdo.SdoAbortOrigin? AbortOrigin { get; } + public ushort Index { get; } + public CanKit.Pro.CANopen.Safety.SafetyValueOrigin Origin { get; } + public System.Collections.Generic.IReadOnlyList RawValue { get; } + public byte Subindex { get; } + public uint? UnsignedValue { get; } + } + public sealed class PeerSafetyFinding + { + public ushort Index { get; } + public CanKit.Pro.CANopen.Safety.PeerSafetyFindingKind Kind { get; } + public string Message { get; } + public byte Subindex { get; } + } + public enum PeerSafetyFindingKind + { + SdoAbort = 0, + AccessRefused = 1, + Unavailable = 2, + InvalidValue = 3, + InvalidMapping = 4, + SignatureMismatch = 5, + ExpectedMismatch = 6, + ExpectedUnavailable = 7, + ConfigurationChanged = 8, + NormativeUncertainty = 9, + } public sealed class PeerSafetyMismatch { public byte[] Actual { get; } @@ -932,12 +967,56 @@ namespace CanKit.Pro.CANopen.Safety public bool Stored { get; } public bool Succeeded { get; } } + public sealed class PeerSafetySnapshot + { + public System.Guid ConfigurationId { get; } + public CanKit.Pro.CANopen.Safety.PeerSafetySnapshotConsistency Consistency { get; } + public bool? DeviceConfigurationValid { get; } + public System.TimeSpan Duration { get; } + public System.Collections.Generic.IReadOnlyList Entries { get; } + public CanKit.Pro.CANopen.Safety.SafetyComparison ExpectedComparison { get; } + public System.Collections.Generic.IReadOnlyList Findings { get; } + public bool IsComplete { get; } + public byte PeerNodeId { get; } + public System.Collections.Generic.IReadOnlyList RecheckEntries { get; } + public System.Collections.Generic.IReadOnlyList Srdos { get; } + public System.DateTimeOffset StartedAtUtc { get; } + } + public enum PeerSafetySnapshotConsistency + { + Unconfirmed = 0, + ChangeObserved = 1, + } + public sealed class PeerSafetySrdoSnapshot + { + public ushort? ComputedSignature { get; } + public bool EligibleForMonitoring { get; } + public System.Collections.Generic.IReadOnlyList Mapping { get; } + public int Number { get; } + public CanKit.Pro.CANopen.Safety.SrdoCommunicationParameter? Parameter { get; } + public CanKit.Pro.CANopen.Safety.SafetyComparison SignatureComparison { get; } + public ushort? StoredSignature { get; } + } public enum PeerSafetyStorage : byte { None = 0, AllParameters = 1, CommunicationParameters = 2, } + public enum SafetyComparison + { + NotRequested = 0, + Unavailable = 1, + Match = 2, + Mismatch = 3, + } + public enum SafetyValueOrigin + { + Unavailable = 0, + Live = 1, + DeviceDescription = 2, + CiA319Default = 3, + } public readonly struct SrdoCommunicationParameter : System.IEquatable { public SrdoCommunicationParameter(CanKit.Pro.CANopen.Safety.SrdoDirection Direction, System.TimeSpan RefreshOrSafeguardCycleTime, System.TimeSpan ValidationTime, uint CobId1, uint CobId2) { } diff --git a/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/CanOpenSafetyInspectionTests.cs b/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/CanOpenSafetyInspectionTests.cs new file mode 100644 index 00000000..651be2e1 --- /dev/null +++ b/tests/CanKit.Pro.Tests/TestCases/CANopen/Safety/CanOpenSafetyInspectionTests.cs @@ -0,0 +1,644 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using CanKit.Abstractions.API.Can; +using CanKit.Abstractions.API.Can.Definitions; +using CanKit.Pro.CANopen; +using CanKit.Pro.CANopen.Safety; +using CanKit.Pro.CANopen.Sdo; +using CanKit.Pro.RawCan; +using CanKit.Pro.Tests.Infrastructure; +using Xunit; + +namespace CanKit.Pro.Tests.TestCases.CANopen.Safety; + +/// Read-only acquisition and diagnostics (FR-CO-060; #403). +public class CanOpenSafetyInspectionTests : IClassFixture +{ + private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30); + private static CanOpenDeviceDescription Description() => CanOpenDeviceDescription.ParseDcf(CanOpenFixtures.Text("safety.dcf")); + private static SrdoCommunicationParameter Parameter => new(SrdoDirection.Transmit, + TimeSpan.FromMilliseconds(30), TimeSpan.FromMilliseconds(20), 0x109, 0x10A); + private static PeerSafetyConfiguration Expected() => new PeerSafetyConfiguration { GlobalFailsafeCommandEnabled = true } + .Add(1, Parameter, new SrdoMapping().Add(0x2000, 0, 16)); + + private static Dictionary<(ushort, byte), byte[]> Image() + { + var p = Parameter; + ushort crc = SrdoCrc.Compute(p, new SrdoMapping().Add(0x2000, 0, 16)); + return new() + { + [(0x1018, 1)] = ObjectDictionary.EncodeU32(0x100), + [(0x1018, 2)] = ObjectDictionary.EncodeU32(0x1001), + [(0x1018, 3)] = ObjectDictionary.EncodeU32(0x10000), + [(0x1018, 4)] = ObjectDictionary.EncodeU32(123), + [(0x1300, 0)] = new byte[] { 1 }, + [(0x13FE, 0)] = new byte[] { 0xA5 }, + [(0x13FF, 0)] = new byte[] { 1 }, + [(0x1301, 0)] = new byte[] { 6 }, + [(0x1301, 1)] = new byte[] { 1 }, + [(0x1301, 2)] = new byte[] { 30, 0 }, + [(0x1301, 3)] = new byte[] { 20 }, + [(0x1301, 4)] = new byte[] { 254 }, + [(0x1301, 5)] = ObjectDictionary.EncodeU32(0x109), + [(0x1301, 6)] = ObjectDictionary.EncodeU32(0x10A), + [(0x1381, 0)] = new byte[] { 2 }, + [(0x1381, 1)] = ObjectDictionary.EncodeU32(0x20000010), + [(0x1381, 2)] = ObjectDictionary.EncodeU32(0x20000010), + [(0x13FF, 1)] = new[] { (byte)crc, (byte)(crc >> 8) }, + }; + } + + private sealed class Rig : IDisposable + { + public readonly Dictionary<(ushort, byte), byte[]> Values = Image(); + public readonly Dictionary<(ushort, byte), uint> Aborts = new(); + public readonly ConcurrentQueue Sent = new(); + public readonly ConcurrentDictionary<(ushort, byte), int> Reads = new(); + public readonly ManualTimeSource Clock = new(); + public readonly ControllableBus Bus; + public readonly CanOpenNode Master; + public Action? BeforeReply; + public (ushort, byte)? Hold; + public readonly TaskCompletionSource Holding = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _boot = new(TaskCreationOptions.RunContinuationsAsynchronously); + private Action? _reply; + private bool _recording; + + public Rig() + { + Bus = ControllableBus.Plain($"inspection-{Guid.NewGuid():N}"); + Bus.OnTransmitting = frame => + { + if (frame.ID == 0x701) { _boot.TrySetResult(true); return; } + if (_recording) Sent.Enqueue(frame); + if (frame.ID != 0x605 || frame.Data.Span[0] != 0x40) return; + var d = frame.Data.ToArray(); + ushort index = (ushort)(d[1] | d[2] << 8); + byte sub = d[3]; + int read = Reads.AddOrUpdate((index, sub), 1, (_, n) => n + 1); + BeforeReply?.Invoke(index, sub, read); + void Reply() + { + var response = new byte[8]; + response[1] = d[1]; response[2] = d[2]; response[3] = sub; + uint abort = Aborts.TryGetValue((index, sub), out var configured) ? configured : Values.ContainsKey((index, sub)) ? 0u : 0x06090011u; + if (abort != 0) + { + response[0] = 0x80; + ObjectDictionary.EncodeU32(abort).CopyTo(response, 4); + } + else + { + var value = Values[(index, sub)]; + response[0] = (byte)(0x43 | ((4 - value.Length) << 2)); + value.CopyTo(response, 4); + } + Bus.RaiseObserved(CanFrame.Classic(0x585, response), isEcho: false); + } + if (Hold == (index, sub)) { Hold = null; _reply = Reply; Holding.TrySetResult(true); } + else Reply(); + }; + Master = new CanOpenNode(new CanBusService(Bus), 1, new CanOpenNodeOptions(), ownsService: true, Clock); + Master.BindPeerDeviceDescription(5, Description()); + } + public async Task Start() + { + await _boot.Task.WaitAsync(Watchdog); + await Master.PostToActorAsync(() => { }).WaitAsync(Watchdog); + _recording = true; + } + public void Release() { var reply = _reply; _reply = null; reply?.Invoke(); } + public Task Read(PeerSafetyConfiguration? expected = null, CancellationToken ct = default) + => Master.Safety().ReadPeerSafetyConfigurationAsync(5, expected, ct).WaitAsync(Watchdog); + public void Dispose() { Master.Dispose(); Bus.Dispose(); } + } + + [Fact] + public async Task Reads_Without_An_Expected_Dcf_And_Separates_The_Flag_Signature_And_Expectation() + { + using var r = new Rig(); await r.Start(); + var snapshot = await r.Read(); + snapshot.IsComplete.Should().BeTrue(); + snapshot.DeviceConfigurationValid.Should().BeTrue(); + snapshot.ExpectedComparison.Should().Be(SafetyComparison.NotRequested); + snapshot.Consistency.Should().Be(PeerSafetySnapshotConsistency.Unconfirmed); + var srdo = snapshot.Srdos.Single(); + srdo.Parameter.Should().Be(Parameter); + srdo.SignatureComparison.Should().Be(SafetyComparison.Match); + srdo.EligibleForMonitoring.Should().BeTrue(); + srdo.Mapping.Single().Index.Should().Be(0x2000); + snapshot.PeerNodeId.Should().Be(5); + snapshot.StartedAtUtc.Offset.Should().Be(TimeSpan.Zero); + snapshot.Duration.Should().BeGreaterThanOrEqualTo(TimeSpan.Zero); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.NormativeUncertainty); + snapshot.Entries.Single(e => e.Index == 0x1018 && e.Subindex == 1).UnsignedValue.Should().Be(0x100); + r.Sent.Should().NotBeEmpty().And.OnlyContain(f => f.ID == 0x605 && f.Data.ToArray()[0] == 0x40, + "inspection may only send SDO uploads, no downloads, NMT, SRDO, GFC or store commands"); + r.Reads.Values.Should().OnlyContain(count => count == 2); + var second = await r.Read(Expected()); + second.ExpectedComparison.Should().Be(SafetyComparison.Match); + second.ConfigurationId.Should().NotBe(snapshot.ConfigurationId); + ((IList)snapshot.Entries[0].RawValue).Invoking(v => v[0] = 99).Should().Throw(); + r.Values[(0x1018, 1)][0] = 99; + snapshot.Entries[0].UnsignedValue.Should().Be(0x100); + } + + [Fact] + public async Task A_True_Device_Flag_Does_Not_Hide_A_Bad_Signature_Or_Expected_Difference() + { + using var r = new Rig(); await r.Start(); + r.Values[(0x13FF, 1)] = new byte[] { 0, 0 }; + var snapshot = await r.Read(Expected()); + snapshot.DeviceConfigurationValid.Should().BeTrue(); + snapshot.Srdos[0].SignatureComparison.Should().Be(SafetyComparison.Mismatch); + snapshot.ExpectedComparison.Should().Be(SafetyComparison.Mismatch); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.SignatureMismatch); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.ExpectedMismatch); + } + + [Fact] + public async Task Partial_Reads_Retain_File_Provenance_And_Abort_Without_Verifying_A_Mixed_Image() + { + using var r = new Rig(); await r.Start(); + r.Aborts[(0x1301, 2)] = 0x08000022; + var snapshot = await r.Read(Expected()); + var entry = snapshot.Entries.Single(e => e.Index == 0x1301 && e.Subindex == 2); + entry.Origin.Should().Be(SafetyValueOrigin.DeviceDescription); + entry.AbortCode.Should().Be(0x08000022); + entry.AbortOrigin.Should().Be(SdoAbortOrigin.Peer); + snapshot.IsComplete.Should().BeFalse(); + snapshot.Srdos[0].ComputedSignature.Should().BeNull(); + snapshot.Srdos[0].SignatureComparison.Should().Be(SafetyComparison.Unavailable); + snapshot.ExpectedComparison.Should().Be(SafetyComparison.Unavailable); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.SdoAbort && f.Index == 0x1301 && f.Subindex == 2); + r.Sent.Should().OnlyContain(f => f.ID == 0x605 && f.Data.ToArray()[0] == 0x40); + } + + [Fact] + public async Task Changed_Live_Values_Are_Retained_And_Disqualify_The_Whole_Evaluation_Period() + { + using var r = new Rig(); await r.Start(); + r.BeforeReply = (index, sub, read) => { if ((index, sub, read) == (0x1301, 2, 2)) r.Values[(index, sub)] = new byte[] { 31, 0 }; }; + var snapshot = await r.Read(); + snapshot.Consistency.Should().Be(PeerSafetySnapshotConsistency.ChangeObserved); + snapshot.Entries.Single(e => e.Index == 0x1301 && e.Subindex == 2).UnsignedValue.Should().Be(30); + snapshot.RecheckEntries.Single(e => e.Index == 0x1301 && e.Subindex == 2).UnsignedValue.Should().Be(31); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.ConfigurationChanged); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + } + + [Fact] + public async Task Holds_The_Peer_Channel_Snapshots_The_Binding_And_Releases_On_Cancellation() + { + using var r = new Rig(); await r.Start(); + r.Hold = (0x13FF, 0); + using var cancel = new CancellationTokenSource(); + var pending = r.Read(ct: cancel.Token); + await r.Holding.Task.WaitAsync(Watchdog); + r.Master.PeerSdoChannelIsFreeForTests(5).Should().BeFalse("the acquisition reserves the peer channel"); + await Assert.ThrowsAsync(() => r.Master.SdoUploadAsync(5, 0x1018, 1)); + r.Master.UnbindPeerDeviceDescription(5); + cancel.Cancel(); + await Assert.ThrowsAnyAsync(() => pending); + r.Master.PeerSdoChannelIsFreeForTests(5).Should().BeTrue(); + r.Release(); + r.Master.BindPeerDeviceDescription(5, Description()); + (await r.Read()).IsComplete.Should().BeTrue(); + } + + [Fact] + public async Task A_Rebind_During_Acquisition_Does_Not_Change_Its_Access_Gate() + { + using var r = new Rig(); await r.Start(); + r.Hold = (0x13FF, 0); + var expected = Expected(); + var pending = r.Read(expected); + await r.Holding.Task.WaitAsync(Watchdog); + r.Master.UnbindPeerDeviceDescription(5); + expected.Add(1, Parameter with { RefreshOrSafeguardCycleTime = TimeSpan.FromMilliseconds(40) }, new SrdoMapping().Add(0x2000, 0, 16)); + r.Release(); + var snapshot = await pending; + snapshot.IsComplete.Should().BeTrue(); + snapshot.ExpectedComparison.Should().Be(SafetyComparison.Match, "the expectation was captured before the pause"); + await Assert.ThrowsAsync(() => r.Read()); + } + + [Fact] + public async Task No_Bound_Description_Is_Refused_Before_Any_Traffic() + { + using var r = new Rig(); await r.Start(); + r.Master.UnbindPeerDeviceDescription(5); + await Assert.ThrowsAsync(() => r.Read()); + r.Sent.Should().BeEmpty(); + r.Master.PeerSdoChannelIsFreeForTests(5).Should().BeTrue(); + } + public static IEnumerable InvalidRecords() + { + yield return new object[] { (ushort)0x1301, (byte)0, new byte[] { 7 }, PeerSafetyFindingKind.InvalidValue }; + yield return new object[] { (ushort)0x1301, (byte)1, new byte[] { 3 }, PeerSafetyFindingKind.InvalidValue }; + yield return new object[] { (ushort)0x1301, (byte)2, new byte[] { 0, 0 }, PeerSafetyFindingKind.InvalidValue }; + yield return new object[] { (ushort)0x1301, (byte)3, new byte[] { 0 }, PeerSafetyFindingKind.InvalidValue }; + yield return new object[] { (ushort)0x1301, (byte)4, new byte[] { 0 }, PeerSafetyFindingKind.InvalidValue }; + yield return new object[] { (ushort)0x1301, (byte)5, ObjectDictionary.EncodeU32(0), PeerSafetyFindingKind.InvalidValue }; + yield return new object[] { (ushort)0x1301, (byte)6, ObjectDictionary.EncodeU32(0), PeerSafetyFindingKind.InvalidValue }; + yield return new object[] { (ushort)0x1301, (byte)2, new byte[] { 1, 2, 3 }, PeerSafetyFindingKind.InvalidValue }; + yield return new object[] { (ushort)0x1381, (byte)0, new byte[] { 0 }, PeerSafetyFindingKind.InvalidMapping }; + yield return new object[] { (ushort)0x1381, (byte)0, new byte[] { 1 }, PeerSafetyFindingKind.InvalidMapping }; + yield return new object[] { (ushort)0x1381, (byte)0, new byte[] { 17 }, PeerSafetyFindingKind.InvalidMapping }; + yield return new object[] { (ushort)0x1381, (byte)0, new byte[] { 129 }, PeerSafetyFindingKind.InvalidMapping }; + yield return new object[] { (ushort)0x1381, (byte)0, new byte[] { 2, 0 }, PeerSafetyFindingKind.InvalidMapping }; + yield return new object[] { (ushort)0x1381, (byte)1, ObjectDictionary.EncodeU32(0), PeerSafetyFindingKind.InvalidMapping }; + yield return new object[] { (ushort)0x1381, (byte)2, ObjectDictionary.EncodeU32(0), PeerSafetyFindingKind.InvalidMapping }; + yield return new object[] { (ushort)0x13FE, (byte)0, new byte[] { 1 }, PeerSafetyFindingKind.InvalidValue }; + } + + [Theory] + [MemberData(nameof(InvalidRecords))] + public async Task Invalid_Raw_Records_Are_Retained_And_Are_Not_Monitor_Input(ushort index, byte sub, byte[] raw, PeerSafetyFindingKind finding) + { + using var r = new Rig(); await r.Start(); + r.Values[(index, sub)] = raw; + var snapshot = await r.Read(); + snapshot.Entries.Single(e => e.Index == index && e.Subindex == sub).RawValue.Should().Equal(raw); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + snapshot.Findings.Should().Contain(f => f.Kind == finding); + if (index == 0x1381 && sub == 0 && (raw.Length != 1 || raw[0] > 128)) snapshot.IsComplete.Should().BeFalse(); + } + + [Theory] + [InlineData(0x1301, 0)] + [InlineData(0x1301, 1)] + [InlineData(0x1301, 2)] + [InlineData(0x1301, 3)] + [InlineData(0x1301, 4)] + [InlineData(0x1301, 5)] + [InlineData(0x1301, 6)] + [InlineData(0x1381, 0)] + [InlineData(0x1381, 1)] + [InlineData(0x1381, 2)] + [InlineData(0x13FF, 1)] + [InlineData(0x13FE, 0)] + public async Task Any_Missing_Required_Entry_Prevents_Eligibility(int index, int sub) + { + using var r = new Rig(); await r.Start(); + r.Aborts[((ushort)index, (byte)sub)] = 0x06090011; + var snapshot = await r.Read(); + snapshot.IsComplete.Should().BeFalse(); + snapshot.Srdos.Should().OnlyContain(s => !s.EligibleForMonitoring); + } + + [Theory] + [InlineData(0)] + [InlineData(65)] + [InlineData(3)] + public async Task Peer_Counts_Do_Not_Allow_An_Unbounded_Scan(int count) + { + using var r = new Rig(); await r.Start(); + r.Values[(0x13FF, 0)] = new[] { (byte)count }; + var snapshot = await r.Read(Expected()); + snapshot.Srdos.Count.Should().Be(count == 0 ? 0 : 2, "the bound file declares at most two SRDOs"); + snapshot.ExpectedComparison.Should().Be(SafetyComparison.Unavailable); + if (count > 2) snapshot.IsComplete.Should().BeFalse(); + r.Reads.Keys.Should().NotContain(k => k.Item1 == 0x1303 || k.Item1 == 0x1383); + } + + private static CanOpenDeviceDescription WithoutSrvt() + { + string text = CanOpenFixtures.Text("safety.dcf").Replace("\r\n", "\n"); + int start = text.IndexOf("[1301sub3]", StringComparison.Ordinal); + int end = text.IndexOf("[1301sub4]", StringComparison.Ordinal); + return CanOpenDeviceDescription.ParseDcf(text[..start] + text[end..]); + } + + [Theory] + [InlineData(1, 0x06090011u, SafetyValueOrigin.CiA319Default)] + [InlineData(1, 0x06010000u, SafetyValueOrigin.Unavailable)] + [InlineData(2, 0x06090011u, SafetyValueOrigin.Unavailable)] + public async Task Only_An_Unimplemented_Undeclared_Producer_Srvt_Uses_The_Explicit_CiA319_Rule(int direction, uint abort, SafetyValueOrigin origin) + { + using var r = new Rig(); await r.Start(); + r.Master.BindPeerDeviceDescription(5, WithoutSrvt()); + r.Values[(0x1301, 1)] = new[] { (byte)direction }; + r.Aborts[(0x1301, 3)] = abort; + var snapshot = await r.Read(); + snapshot.Entries.Single(e => e.Index == 0x1301 && e.Subindex == 3).Origin.Should().Be(origin); + if (origin == SafetyValueOrigin.CiA319Default) + { + snapshot.Srdos[0].SignatureComparison.Should().Be(SafetyComparison.Match); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeTrue(); + } + else snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + } + + [Fact] + public async Task A_Recheck_That_Becomes_Unavailable_Does_Not_Permit_A_Complete_Image() + { + using var r = new Rig(); await r.Start(); + r.BeforeReply = (index, sub, read) => { if ((index, sub, read) == (0x1301, 2, 2)) r.Aborts[(index, sub)] = 0x08000022; }; + var snapshot = await r.Read(); + snapshot.IsComplete.Should().BeFalse(); + snapshot.Consistency.Should().Be(PeerSafetySnapshotConsistency.Unconfirmed); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Deleted_Records_Do_Not_Report_A_Stale_Checksum_As_A_Failure(bool clearMapping) + { + using var r = new Rig(); await r.Start(); + r.Values[(0x1301, 1)] = new byte[] { 0 }; + if (clearMapping) r.Values[(0x1381, 0)] = new byte[] { 0 }; + r.Values[(0x13FF, 1)] = new byte[] { 0x12, 0x34 }; + var snapshot = await r.Read(new PeerSafetyConfiguration { GlobalFailsafeCommandEnabled = true }); + snapshot.ExpectedComparison.Should().Be(SafetyComparison.Match); + snapshot.Srdos[0].SignatureComparison.Should().Be(SafetyComparison.NotRequested); + snapshot.Srdos[0].Mapping.Should().BeEmpty(); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + snapshot.Findings.Should().NotContain(f => f.Kind == PeerSafetyFindingKind.SignatureMismatch); + } + + [Fact] + public async Task Shared_CobIds_Disqualify_Both_Records() + { + using var r = new Rig(); await r.Start(); + r.Values[(0x13FF, 0)] = new byte[] { 2 }; + r.Values[(0x13FF, 2)] = Image()[(0x13FF, 1)]; + foreach (var entry in Image().Where(e => e.Key.Item1 is 0x1301 or 0x1381)) + r.Values[((ushort)(entry.Key.Item1 + 1), entry.Key.Item2)] = entry.Value; + var snapshot = await r.Read(); + snapshot.Srdos.Should().OnlyContain(s => !s.EligibleForMonitoring); + snapshot.Findings.Should().Contain(f => f.Message.Contains("also used")); + } + + [Theory] + [InlineData(0x20000000u)] + [InlineData(0x2000000Cu)] + [InlineData(0x00020008u)] + public async Task Paired_But_Unusable_Mapping_Words_Are_Diagnosed(uint word) + { + using var r = new Rig(); await r.Start(); + r.Values[(0x1381, 1)] = r.Values[(0x1381, 2)] = ObjectDictionary.EncodeU32(word); + var snapshot = await r.Read(); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.InvalidMapping); + } + + [Fact] + public async Task A_Valid_Signature_With_A_Clear_Device_Flag_Is_Not_Eligible() + { + using var r = new Rig(); await r.Start(); + r.Values[(0x13FE, 0)] = new byte[] { 0 }; + var snapshot = await r.Read(); + snapshot.DeviceConfigurationValid.Should().BeFalse(); + snapshot.Srdos[0].SignatureComparison.Should().Be(SafetyComparison.Match); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + } + + [Fact] + public async Task A_Malformed_Count_Uses_Only_The_Description_Bound_And_Reports_Partial_Data() + { + using var r = new Rig(); await r.Start(); + r.Values[(0x13FF, 0)] = new byte[] { 1, 0 }; + var snapshot = await r.Read(); + snapshot.IsComplete.Should().BeFalse(); + snapshot.Srdos.Count.Should().Be(2); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.InvalidValue && f.Index == 0x13FF); + } + + [Fact] + public async Task A_Local_Timeout_Is_Retained_And_Does_Not_Apply_The_Peer_Srvt_Default_Rule() + { + using var r = new Rig(); await r.Start(); + r.Master.BindPeerDeviceDescription(5, WithoutSrvt()); + r.Hold = (0x1301, 3); + var pending = r.Read(); + await r.Holding.Task.WaitAsync(Watchdog); + await r.Master.PostToActorAsync(() => { }).WaitAsync(Watchdog); + r.Clock.Advance(TimeSpan.FromSeconds(2)); + await r.Master.PostToActorAsync(() => { }).WaitAsync(Watchdog); + var snapshot = await pending; + var entry = snapshot.Entries.Single(e => e.Index == 0x1301 && e.Subindex == 3); + entry.AbortOrigin.Should().Be(SdoAbortOrigin.Local); + entry.Origin.Should().Be(SafetyValueOrigin.Unavailable); + snapshot.IsComplete.Should().BeFalse(); + } + + [Fact] + public async Task A_Transmit_Refusal_Produces_An_Unavailable_Finding_And_Keeps_File_Provenance() + { + using var r = new Rig(); await r.Start(); + bool fail = true; + r.BeforeReply = (index, sub, _) => + { + if ((index, sub) == (0x1301, 5) && fail) { fail = false; throw new InvalidOperationException("injected transmit refusal"); } + }; + var snapshot = await r.Read(); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.Unavailable && f.Index == 0x1301 && f.Subindex == 5); + snapshot.Entries.Single(e => e.Index == 0x1301 && e.Subindex == 5).Origin.Should().Be(SafetyValueOrigin.DeviceDescription); + snapshot.IsComplete.Should().BeFalse(); + } + + [Theory] + [InlineData("nonsense")] + [InlineData("65536")] + public async Task Unusable_File_Values_Are_Not_Truncated_Or_Manufactured(string textValue) + { + using var r = new Rig(); await r.Start(); + string text = CanOpenFixtures.Text("safety.dcf").Replace("\r\n", "\n"); + int a = text.IndexOf("[1301sub2]", StringComparison.Ordinal), b = text.IndexOf("[1301sub3]", StringComparison.Ordinal); + text = text[..a] + $"[1301sub2]\nParameterName=Cycle\nObjectType=0x7\nDataType=0x0006\nAccessType=rw\nParameterValue={textValue}\nPDOMapping=0\n\n" + text[b..]; + r.Master.BindPeerDeviceDescription(5, CanOpenDeviceDescription.ParseDcf(text)); + r.Aborts[(0x1301, 2)] = 0x08000022; + var snapshot = await r.Read(); + snapshot.Entries.Single(e => e.Index == 0x1301 && e.Subindex == 2).Origin.Should().Be(SafetyValueOrigin.Unavailable); + snapshot.Srdos[0].ComputedSignature.Should().BeNull(); + } + + [Theory] + [InlineData(0, null)] + [InlineData(1, 1u)] + [InlineData(2, 513u)] + [InlineData(3, null)] + [InlineData(4, 67305985u)] + public void Raw_Value_Interpretation_Is_Width_Checked_And_Little_Endian(int length, uint? expected) + { + var entry = new PeerSafetyEntry(0x1301, 2, Enumerable.Range(1, length).Select(n => (byte)n).ToArray(), SafetyValueOrigin.Live); + entry.UnsignedValue.Should().Be(expected); + } + + [Fact] + public void A_Missing_Recheck_Cannot_Become_A_Complete_Image() + { + var entries = Image().Select(e => new PeerSafetyEntry(e.Key.Item1, e.Key.Item2, e.Value, SafetyValueOrigin.Live)).ToArray(); + var rechecks = entries.Where(e => e.Index != 0x1301 || e.Subindex != 2).ToArray(); + var snapshot = PeerSafetyDiagnostics.Build(5, DateTimeOffset.UtcNow, TimeSpan.Zero, entries, rechecks, + new List(), 1, true, false, new Dictionary<(ushort, byte), byte[]>()); + snapshot.IsComplete.Should().BeFalse(); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + } + + [Fact] + public void Inspection_Extension_Rejects_A_Null_Implementation() + => Assert.Throws(() => { _ = CanOpenSafetyExtensions.ReadPeerSafetyConfigurationAsync(null!, 5); }); + +#if NET5_0_OR_GREATER + [Fact] + public void Existing_Safety_Implementations_Need_Not_Add_Inspection() + { + var foreign = System.Reflection.DispatchProxy.Create(); + Assert.Throws(() => { _ = foreign.ReadPeerSafetyConfigurationAsync(5); }); + } + private class ForeignSafety : System.Reflection.DispatchProxy + { + protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args) => throw new NotSupportedException(); + } +#endif + + [Fact] + public async Task A_Checksummed_Mapping_Still_Has_To_Match_The_Bound_Object_Width() + { + using var r = new Rig(); await r.Start(); + var map = new SrdoMapping().Add(0x2000, 0, 8); + r.Values[(0x1381, 1)] = r.Values[(0x1381, 2)] = ObjectDictionary.EncodeU32(0x20000008); + ushort crc = SrdoCrc.Compute(Parameter, map); + r.Values[(0x13FF, 1)] = new[] { (byte)crc, (byte)(crc >> 8) }; + var snapshot = await r.Read(); + snapshot.Srdos[0].SignatureComparison.Should().Be(SafetyComparison.Match); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.InvalidMapping && f.Message.Contains("not as wide")); + } + + [Fact] + public async Task A_Readonly_Producer_Srvt_Outside_The_Existing_CiA319_Rule_Is_Reported_Without_Rewriting_It() + { + using var r = new Rig(); await r.Start(); + string text = CanOpenFixtures.Text("safety.dcf").Replace("\r\n", "\n"); + int a = text.IndexOf("[1301sub3]", StringComparison.Ordinal), b = text.IndexOf("[1301sub4]", StringComparison.Ordinal); + text = text[..a] + text[a..b].Replace("AccessType=rw", "AccessType=ro") + text[b..]; + r.Master.BindPeerDeviceDescription(5, CanOpenDeviceDescription.ParseDcf(text)); + r.Values[(0x1301, 3)] = new byte[] { 30 }; + ushort crc = SrdoCrc.Compute(Parameter with { ValidationTime = TimeSpan.FromMilliseconds(30) }, new SrdoMapping().Add(0x2000, 0, 16)); + r.Values[(0x13FF, 1)] = new[] { (byte)crc, (byte)(crc >> 8) }; + var snapshot = await r.Read(); + snapshot.Entries.Single(e => e.Index == 0x1301 && e.Subindex == 3).UnsignedValue.Should().Be(30); + snapshot.Srdos[0].SignatureComparison.Should().Be(SafetyComparison.Match); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.InvalidValue && f.Message.Contains("20 ms")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_Consumer_Record_Requires_A_Writable_Mapped_Object(bool writable) + { + using var r = new Rig(); await r.Start(); + if (writable) + { + string text = CanOpenFixtures.Text("safety.dcf").Replace("\r\n", "\n"); + int at = text.IndexOf("[2000]", StringComparison.Ordinal); + text = text[..at] + text[at..].Replace("AccessType=ro", "AccessType=rw"); + r.Master.BindPeerDeviceDescription(5, CanOpenDeviceDescription.ParseDcf(text)); + } + r.Values[(0x1301, 1)] = new byte[] { 2 }; + ushort crc = SrdoCrc.Compute(Parameter with { Direction = SrdoDirection.Receive }, new SrdoMapping().Add(0x2000, 0, 16)); + r.Values[(0x13FF, 1)] = new[] { (byte)crc, (byte)(crc >> 8) }; + var snapshot = await r.Read(); + snapshot.Srdos[0].Parameter!.Value.Direction.Should().Be(SrdoDirection.Receive); + snapshot.Srdos[0].EligibleForMonitoring.Should().Be(writable); + } + + [Theory] + [InlineData(0x1301, 2, false)] + [InlineData(0x1018, 1, true)] + [InlineData(0x1300, 0, true)] + public async Task A_Malformed_Recheck_Is_Retained_And_Required_Data_Cannot_Remain_Complete(int index, int sub, bool complete) + { + using var r = new Rig(); await r.Start(); + r.BeforeReply = (i, s, read) => { if (i == index && s == sub && read == 2) r.Values[(i, s)] = new byte[] { 30, 0, 0 }; }; + var snapshot = await r.Read(); + snapshot.IsComplete.Should().Be(complete); + snapshot.Consistency.Should().Be(PeerSafetySnapshotConsistency.ChangeObserved); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.InvalidValue && f.Index == index && f.Subindex == sub); + snapshot.RecheckEntries.Single(e => e.Index == index && e.Subindex == sub).RawValue.Should().Equal(30, 0, 0); + } + + [Fact] + public async Task A_Change_In_Srvt_Evidence_Also_Begins_A_New_Evaluation_Period() + { + using var r = new Rig(); await r.Start(); + r.Master.BindPeerDeviceDescription(5, WithoutSrvt()); + r.BeforeReply = (index, sub, read) => { if ((index, sub, read) == (0x1301, 3, 2)) r.Aborts[(index, sub)] = 0x06090011; }; + var snapshot = await r.Read(); + snapshot.Entries.Single(e => e.Index == 0x1301 && e.Subindex == 3).Origin.Should().Be(SafetyValueOrigin.Live); + snapshot.RecheckEntries.Single(e => e.Index == 0x1301 && e.Subindex == 3).Origin.Should().Be(SafetyValueOrigin.CiA319Default); + snapshot.Consistency.Should().Be(PeerSafetySnapshotConsistency.ChangeObserved); + snapshot.Srdos[0].EligibleForMonitoring.Should().BeFalse(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task An_Unconfirmed_Peer_Count_Cannot_Establish_A_Matching_Configuration(bool abortCount, bool different) + { + using var r = new Rig(); await r.Start(); + r.Values[(0x13FF, 0)] = new byte[] { 3 }; + if (abortCount) r.Aborts[(0x13FF, 0)] = 0x06090011; + foreach (var entry in Image().Where(e => e.Key.Item1 is 0x1301 or 0x1381)) + r.Values[((ushort)(entry.Key.Item1 + 1), entry.Key.Item2)] = entry.Value; + r.Values[(0x1302, 1)] = new byte[] { 0 }; + r.Values[(0x1382, 0)] = new byte[] { 0 }; + r.Values[(0x13FF, 2)] = new byte[] { 0, 0 }; + var expected = different ? new PeerSafetyConfiguration { GlobalFailsafeCommandEnabled = true } + .Add(1, Parameter with { RefreshOrSafeguardCycleTime = TimeSpan.FromMilliseconds(40) }, new SrdoMapping().Add(0x2000, 0, 16)) : Expected(); + var snapshot = await r.Read(expected); + snapshot.IsComplete.Should().BeFalse(); + snapshot.ExpectedComparison.Should().Be(different ? SafetyComparison.Mismatch : SafetyComparison.Unavailable); + } + + [Fact] + public async Task An_Image_That_Changes_During_The_Read_Cannot_Establish_A_Matching_Expectation() + { + using var r = new Rig(); await r.Start(); + r.BeforeReply = (index, sub, read) => { if ((index, sub, read) == (0x13FE, 0, 2)) r.Values[(index, sub)] = new byte[] { 0 }; }; + var snapshot = await r.Read(Expected()); + snapshot.Consistency.Should().Be(PeerSafetySnapshotConsistency.ChangeObserved); + snapshot.ExpectedComparison.Should().Be(SafetyComparison.Unavailable); + snapshot.Findings.Should().Contain(f => f.Kind == PeerSafetyFindingKind.ExpectedUnavailable); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Unused_Records_With_Zero_CobIds_Are_Not_Reported_As_Active_Communication_Errors(bool missingCycle) + { + using var r = new Rig(); await r.Start(); + r.Values[(0x13FF, 0)] = new byte[] { 2 }; + foreach (var entry in Image().Where(e => e.Key.Item1 is 0x1301 or 0x1381)) + r.Values[((ushort)(entry.Key.Item1 + 1), entry.Key.Item2)] = entry.Value; + r.Values[(0x1302, 1)] = new byte[] { 0 }; + r.Values[(0x1302, 5)] = r.Values[(0x1302, 6)] = ObjectDictionary.EncodeU32(0); + r.Values[(0x1382, 0)] = new byte[] { 0 }; + r.Values[(0x13FF, 2)] = new byte[] { 0, 0 }; + if (missingCycle) r.Aborts[(0x1302, 2)] = 0x06090011; + var snapshot = await r.Read(Expected()); + snapshot.Findings.Should().NotContain(f => f.Kind == PeerSafetyFindingKind.InvalidValue && f.Index == 0x1302); + snapshot.IsComplete.Should().Be(!missingCycle); + snapshot.Srdos[0].EligibleForMonitoring.Should().Be(!missingCycle); + snapshot.Srdos[1].SignatureComparison.Should().Be(SafetyComparison.NotRequested); + snapshot.Srdos[1].EligibleForMonitoring.Should().BeFalse(); + if (missingCycle) snapshot.Srdos[1].Parameter.Should().BeNull(); + else snapshot.Srdos[1].Parameter!.Value.Direction.Should().Be(SrdoDirection.None); + } + +}