using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate; /// /// Gitea #592 (hw5) — ONE user-approved native STRING write on a real controller, through the production driver /// (, native transport): snapshot twice ≥ 30 s apart (raw bytes, from the exchange tap), /// write a distinctive value, read it back, restore the exact original, read back, final raw snapshot equals the /// original. Fails closed before ANY write when the tag is not a built-in 88-byte STRING, moved between the two /// snapshots, or holds non-zero bytes past its LEN (the driver's encoder zero-fills, so such a value could not be /// restored byte-exact). /// /// AB_HW_WRITE_GATEWAY — ab://host/1,0; absent ⇒ skip. /// AB_HW_WRITE_STRING_TAG — with the gateway's host, must be a pair on . /// AB_HW_WRITE_APPROVED=1 — the explicit opt-in. /// AB_HW_WRITE_GAP_SECONDS — snapshot gap, default 30, at least 30. /// AB_HW_WRITE_DRYRUN=1 — stop after the read-only preflight (no write). /// /// Refused (skipped) while AB_DIFF_ENDPOINT is set (a read-only differential run). Never set by any other /// run; (the simulator opt-in) is deliberately not /// what enables this. /// [Trait("Category", "LiveIntegration")] public sealed class StringWriteGateTests { /// /// The only (controller, tag) pairs this gate may write: Z-35162's logic-, alias-, trend- and Galaxy-unreferenced /// STRINGs (L5X census + raw grep). Pinned to the host, so the same tag name on another controller is refused. /// pins this list. /// internal static readonly (string Host, string Tag)[] Approved = [ ("10.209.70.160", "Program:ES03_TemperatureControl._StrTmp"), ("10.209.70.160", "Program:ES03_TemperatureControl._Prefix"), ]; /// The host of an ab://host[:port]/path gateway, or null when it is not one. internal static string? GatewayHost(string gateway) { if (!gateway.StartsWith("ab://", StringComparison.OrdinalIgnoreCase)) return null; var authority = gateway[5..].Split('/', 2)[0]; var host = authority.Split(':', 2)[0]; return host.Length == 0 ? null : host; } /// True only for an exact (host, tag) pair on . internal static bool IsApproved(string gateway, string tag) => GatewayHost(gateway) is { } host && Approved.Any(a => string.Equals(a.Host, host, StringComparison.Ordinal) && string.Equals(a.Tag, tag, StringComparison.Ordinal)); private const string TestValue = "OTOPCUA-TEST"; private const int BuiltInStringBytes = 88; private const int StringCapacity = 82; [Fact] public async Task A_native_STRING_write_reads_back_and_restores_the_exact_original() { var gateway = Environment.GetEnvironmentVariable("AB_HW_WRITE_GATEWAY")?.Trim(); if (string.IsNullOrEmpty(gateway)) Assert.Skip("AB_HW_WRITE_GATEWAY not set."); if (Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is { Length: > 0 }) Assert.Skip($"Write gate refused: {IntegrationWriteGate.DifferentialEndpointVar} is set, so a read-only differential run is in progress."); if (Environment.GetEnvironmentVariable("AB_HW_WRITE_APPROVED") != "1") Assert.Skip("AB_HW_WRITE_APPROVED=1 not set."); var tagPath = Environment.GetEnvironmentVariable("AB_HW_WRITE_STRING_TAG")?.Trim() ?? ""; if (!IsApproved(gateway, tagPath)) throw new InvalidOperationException($"('{gateway}', '{tagPath}') is not an approved (controller, tag) pair."); var gap = 30; if (Environment.GetEnvironmentVariable("AB_HW_WRITE_GAP_SECONDS") is { Length: > 0 } rawGap && (!int.TryParse(rawGap, out gap) || gap < 30 || gap > 300)) throw new InvalidOperationException($"AB_HW_WRITE_GAP_SECONDS='{rawGap}' must be in [30, 300]."); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(gap + 120)); var ct = cts.Token; const string Ref = "gate-string"; var diagnostics = DeviceSessionPool.Shared.Diagnostics; var replies = new List(); diagnostics.ExchangeObserver = (req, rep) => { // Read Tag Fragmented (0x52) replies only: service 0xD2, status 0, then type 0x02A0 + handle + data. if (req.Length > 0 && req.Span[0] == 0x52) lock (replies) replies.Add(rep.ToArray()); }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(gateway, AbCipPlcFamily.CompactLogix, DeviceName: "hw5-gate")], RawTags = [ new RawTagEntry(Ref, $$"""{"tagPath":"{{tagPath}}","dataType":"String","writable":true}""", WriteIdempotent: false, DeviceName: gateway), ], Timeout = TimeSpan.FromSeconds(5), Transport = AbCipTransport.Native, Probe = new AbCipProbeOptions { Enabled = false }, EnableControllerBrowse = false, EnableAlarmProjection = false, }, "hw5-string-gate"); try { await drv.InitializeAsync("{}", ct); Log($"Gateway {gateway}; tag '{tagPath}'; transport Native; snapshot gap {gap} s"); var (v1, raw1) = await SnapshotAsync(drv, Ref, replies, "snapshot 1", ct); await Task.Delay(TimeSpan.FromSeconds(gap), ct); var (v2, raw2) = await SnapshotAsync(drv, Ref, replies, "snapshot 2", ct); // Preflight — fail closed before any write. raw1.ShouldBe(raw2, "the tag changed between the two snapshots: something else writes it; NOT writing"); raw1.Length.ShouldBe(4 + BuiltInStringBytes, "expected a built-in STRING (type 0x02A0 + handle + 88 bytes)"); var data = raw1.AsSpan(4); var len = BitConverter.ToInt32(data[..4]); len.ShouldBeInRange(0, StringCapacity); data[(4 + len)..].ToArray().ShouldAllBe(b => b == 0, "bytes past LEN are not zero, so the driver's zero-filling encoder could not restore them exactly; NOT writing"); Log($"Preflight OK: type/handle {Convert.ToHexString(raw1.AsSpan(0, 4))}, LEN {len}, tail zero; original '{v1}'"); if (Environment.GetEnvironmentVariable("AB_HW_WRITE_DRYRUN") == "1") { Log("AB_HW_WRITE_DRYRUN=1: stopping after the read-only preflight; nothing written."); return; } var restored = false; byte[] rawTest; try { await WriteAsync(drv, Ref, TestValue, "write test value", ct); (var vTest, rawTest) = await SnapshotAsync(drv, Ref, replies, "read back test value", ct); vTest.ShouldBe(TestValue); await WriteAsync(drv, Ref, v1, "restore original", ct); restored = true; } finally { // Whatever failed after the first write, the original goes back (own token: never skip the restore). if (!restored) await WriteAsync(drv, Ref, v1, "restore original (after a failure)", CancellationToken.None); } var (vRestored, rawRestored) = await SnapshotAsync(drv, Ref, replies, "read back restored", ct); vRestored.ShouldBe(v1); rawRestored.ShouldBe(raw1, "restored bytes must equal the original byte for byte"); Log($"Test-value raw LEN {BitConverter.ToInt32(rawTest.AsSpan(4, 4))}; restored raw equals original: {rawRestored.SequenceEqual(raw1)}"); await Task.Delay(TimeSpan.FromSeconds(2), ct); var (vFinal, rawFinal) = await SnapshotAsync(drv, Ref, replies, "final snapshot", ct); rawFinal.ShouldBe(raw1); vFinal.ShouldBe(v1); Log("RESULT: write, read-back, exact restore — PASS"); } finally { diagnostics.ExchangeObserver = null; await drv.ShutdownAsync(CancellationToken.None); } } private static async Task<(string Value, byte[] Raw)> SnapshotAsync( AbCipDriver drv, string reference, List replies, string label, CancellationToken ct) { lock (replies) replies.Clear(); var snap = (await drv.ReadAsync([reference], ct)).Single(); byte[] reply; lock (replies) { // The first read of a fresh runtime is two wire reads (the create/existence read, then the read itself). replies.Count.ShouldBeInRange(1, 2, $"{label}: Read Tag Fragmented replies on the wire"); replies.ShouldAllBe(r => r.SequenceEqual(replies[0]), $"{label}: the wire reads of one call disagree"); reply = replies[^1]; } reply[2].ShouldBe((byte)0, $"{label}: CIP general status"); var raw = reply.AsSpan(4).ToArray(); // type (2) + handle (2) + data Log($"{label}: status 0x{snap.StatusCode:X8}, value '{snap.Value}', raw ({raw.Length} B) {Convert.ToHexString(raw)}"); snap.StatusCode.ShouldBe(0u, label); return ((string)snap.Value!, raw); } private static async Task WriteAsync(AbCipDriver drv, string reference, string value, string label, CancellationToken ct) { var result = (await drv.WriteAsync([new WriteRequest(reference, value)], ct)).Single(); Log($"{label}: '{value}' (ASCII {Convert.ToHexString(Encoding.ASCII.GetBytes(value))}) -> status 0x{result.StatusCode:X8}"); result.StatusCode.ShouldBe(0u, label); } private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line); }