130 lines
7.1 KiB
C#
130 lines
7.1 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
|
|
|
if (args.Length != 3) throw new ArgumentException("Usage: Probe <endpoint> <None|SignAndEncrypt> <new-result-directory>");
|
|
var endpoint = args[0];
|
|
var mode = Enum.Parse<OpcUaSecurityMode>(args[1]);
|
|
var root = Path.GetFullPath(args[2]);
|
|
if (Directory.Exists(root)) throw new IOException("Refusing to reuse an evidence/PKI directory.");
|
|
Directory.CreateDirectory(root);
|
|
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
|
var ct = deadline.Token;
|
|
var cases = new List<object>();
|
|
var failures = 0;
|
|
var options = new OpcUaGlobalOptions
|
|
{
|
|
ApplicationName = "ScadaBridge-SDK2-compatibility",
|
|
TrustedIssuerStorePath = Path.Combine(root, "pki", "issuers"),
|
|
TrustedPeerStorePath = Path.Combine(root, "pki", "trusted"),
|
|
RejectedCertificateStorePath = Path.Combine(root, "pki", "rejected"),
|
|
};
|
|
// Populate only this probe's trust list; never change the consumer's existing stores.
|
|
var trustSource = Environment.GetEnvironmentVariable("SCADABRIDGE_PROBE_SERVER_CERTS");
|
|
if (!string.IsNullOrEmpty(trustSource))
|
|
{
|
|
var destination = Path.Combine(options.TrustedPeerStorePath, "certs");
|
|
Directory.CreateDirectory(destination);
|
|
foreach (var cert in Directory.GetFiles(trustSource, "*.der")) File.Copy(cert, Path.Combine(destination, Path.GetFileName(cert)));
|
|
}
|
|
var factory = new RealOpcUaClientFactory(options, NullLoggerFactory.Instance);
|
|
await using var adapter = new OpcUaDataConnection(factory, NullLogger<OpcUaDataConnection>.Instance);
|
|
var config = new OpcUaEndpointConfig
|
|
{
|
|
EndpointUrl = endpoint, SecurityMode = mode, AutoAcceptUntrustedCerts = false,
|
|
PublishingIntervalMs = 100, SamplingIntervalMs = 100,
|
|
UserIdentity = new OpcUaUserIdentityConfig
|
|
{
|
|
TokenType = OpcUaUserTokenType.UsernamePassword, Username = "apikey",
|
|
Password = Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing task credential"),
|
|
},
|
|
};
|
|
static void Require(bool value, string reason) { if (!value) throw new InvalidOperationException(reason); }
|
|
async Task Check(string name, Func<Task> action)
|
|
{
|
|
try { await action(); cases.Add(new { name, status = "passed" }); }
|
|
catch (Exception e) { failures++; cases.Add(new { name, status = "failed", error = e.ToString() }); }
|
|
}
|
|
async Task Wait(Func<bool> predicate)
|
|
{
|
|
for (var i = 0; i < 200; i++) { if (predicate()) return; await Task.Delay(50, ct); }
|
|
Require(false, "No expected consumer notification within 10 seconds.");
|
|
}
|
|
const string raw = "nsu=https://zb.com/otopcua/raw;s=Matrix/dev/";
|
|
const string uns = "nsu=https://zb.com/otopcua/uns;s=Eq/";
|
|
await Check("actual-adapter-connect-api-key", () => adapter.ConnectAsync(OpcUaEndpointConfigSerializer.ToFlatDict(config), ct));
|
|
if (failures == 0)
|
|
{
|
|
foreach (var prefix in new[] { raw, uns })
|
|
{
|
|
await Check($"scalar-round-trip-{prefix}", async () =>
|
|
{
|
|
var write = await adapter.WriteAsync(prefix + "Scalar", 412, ct);
|
|
Require(write.Success, write.ErrorMessage ?? "Scalar write denied");
|
|
var read = await adapter.ReadAsync(prefix + "Scalar", ct);
|
|
Require(read.Success && read.Value?.Value is int i && i == 412, "Scalar value/type changed");
|
|
});
|
|
await Check($"array-round-trip-{prefix}", async () =>
|
|
{
|
|
var write = await adapter.WriteAsync(prefix + "Vector", new[] { 31, 32, 33 }, ct);
|
|
Require(write.Success, write.ErrorMessage ?? "Array write denied");
|
|
var read = await adapter.ReadAsync(prefix + "Vector", ct);
|
|
Require(read.Success && read.Value?.Value is int[] a && a.SequenceEqual(new[] { 31, 32, 33 }), "Array value/type changed");
|
|
});
|
|
}
|
|
await Check("actual-adapter-durable-browse", async () =>
|
|
{
|
|
var page = await adapter.BrowseChildrenAsync("nsu=https://zb.com/otopcua/raw;s=Matrix/dev", cancellationToken: ct);
|
|
Require(page.Children.Count(x => x.DisplayName is "Scalar" or "Vector") == 2, "Missing durable raw variable children");
|
|
Require(page.Children.Any(x => x.DisplayName == "Alarm"), "Missing native alarm under its raw parent");
|
|
var projected = await adapter.BrowseChildrenAsync("nsu=https://zb.com/otopcua/uns;s=Eq", cancellationToken: ct);
|
|
Require(projected.Children.Any(x => x.DisplayName == "Vector"), "Missing UNS variable child");
|
|
});
|
|
await Check("actual-adapter-monitor-quality-time", async () =>
|
|
{
|
|
var values = new ConcurrentQueue<TagValue>();
|
|
var handle = await adapter.SubscribeAsync(raw + "Scalar", (_, value) => values.Enqueue(value), ct);
|
|
Require((await adapter.WriteAsync(raw + "Scalar", 713, ct)).Success, "Monitored write denied");
|
|
await Wait(() => values.Any(x => x.Value is int i && i == 713));
|
|
var actual = values.Last(x => x.Value is int i && i == 713);
|
|
Require(actual.Timestamp > new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), "Missing source timestamp");
|
|
Require(actual.Quality.ToString() == "Good", "Quality was not Good");
|
|
await adapter.UnsubscribeAsync(handle, ct);
|
|
});
|
|
await Check("actual-adapter-alarm-filter-refresh", async () =>
|
|
{
|
|
var alarms = new ConcurrentQueue<NativeAlarmTransition>();
|
|
var handle = await adapter.SubscribeAlarmsAsync("i=2253", null, alarms.Enqueue, ct);
|
|
await Wait(() => alarms.Any(x => x.Kind == AlarmTransitionKind.SnapshotComplete)
|
|
&& alarms.Any(x => x.Message == "matrix-alarm" && x.SourceReference.Contains("Alarm", StringComparison.Ordinal)));
|
|
await adapter.UnsubscribeAlarmsAsync(handle, ct);
|
|
});
|
|
await Check("actual-adapter-disconnect-reconnect", async () =>
|
|
{
|
|
await adapter.DisconnectAsync(ct);
|
|
await adapter.ConnectAsync(OpcUaEndpointConfigSerializer.ToFlatDict(config), ct);
|
|
Require((await adapter.ReadAsync(raw + "Scalar", ct)).Success, "Consumer failed after reconnect");
|
|
await adapter.DisconnectAsync(ct);
|
|
});
|
|
}
|
|
var sdkAssembly = typeof(Opc.Ua.NodeId).Assembly;
|
|
var result = new
|
|
{
|
|
consumer = typeof(OpcUaDataConnection).Assembly.GetName().Version?.ToString(),
|
|
sdk = FileVersionInfo.GetVersionInfo(sdkAssembly.Location).ProductVersion,
|
|
process = Environment.ProcessId, mode = mode.ToString(), autoAccept = false,
|
|
endpoint, cases, failures,
|
|
notImplementedByConsumer = new[] { "HistoryRead/CP", "Part9 acknowledgement methods" },
|
|
};
|
|
File.WriteAllText(Path.Combine(root, "result.json"), JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
|
Console.WriteLine($"Consumer probe: {cases.Count - failures} passed, {failures} failed; {Path.Combine(root, "result.json")}");
|
|
return failures == 0 ? 0 : 1;
|