Files
histsdk/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
T
Joseph Doherty fbd839077b R1.4 GetHistorianInfo: bounded out on 2020 WCF (named-value-only, no struct)
Captured the native HistorianAccess.GetHistorianInfo(out HistorianInfo, out err)
and decoded the wire: over 2020 WCF, GETHI is a named-value query whose only
working key is "HistorianVersion" (response ~30 bytes = the version string).
Probed 7 storage-mode key names -> all ok=False/err. The 518-byte HISTORIAN_INFO
struct + EventStorageMode@514 is the 2023R2 HCAL-native/gRPC model (confirmed
from the decompiled 2023R2 source); on 2020 the native client derives the mode
outside the WCF wire.

Version is already exposed (ProbeAsync/GetRuntimeParameterAsync), so no hollow
GetHistorianInfoAsync is shipped (same disposition as R1.3 timezone). This
completes the reachable 2020-WCF M1 read surface; remaining M1 = config writes
(gated on explicit request) or gRPC/2023R2-only items.

RE aids kept: harness `historian-info` scenario, Capture-HistorianInfo.ps1,
decode-historian-info-capture.py, and StringHandleProbeDiagnosticTests
.GETHI_CandidateInfoNames (asserts the named-value-only finding; gated).
Docs: wcf-historian-info.md (new) + roadmap/matrix/wall-doc updates. 230 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
2026-06-20 23:42:27 -04:00

1787 lines
86 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Web.Script.Serialization;
namespace AVEVA.Historian.NativeTraceHarness;
internal static class Program
{
private static int Main(string[] args)
{
string repoRoot = FindRepoRoot();
Directory.SetCurrentDirectory(repoRoot);
string tagName = GetArg(args, "--tag") ?? "OtOpcUaParityTest_001.Counter";
string serverName = GetArg(args, "--server-name") ?? "localhost";
int tcpPort = int.TryParse(GetArg(args, "--tcp-port"), out int parsedTcpPort) ? parsedTcpPort : 32568;
int lookbackMinutes = int.TryParse(GetArg(args, "--lookback-minutes"), out int parsedLookback) ? parsedLookback : 1440;
int maxRows = int.TryParse(GetArg(args, "--max-rows"), out int parsedMaxRows) ? parsedMaxRows : 1;
int waitSeconds = int.TryParse(GetArg(args, "--connection-wait-seconds"), out int parsedWait) ? parsedWait : 15;
int preLoadSleepSeconds = int.TryParse(GetArg(args, "--pre-load-sleep-seconds"), out int parsedPreLoadSleep) ? parsedPreLoadSleep : 0;
int preOpenSleepSeconds = int.TryParse(GetArg(args, "--pre-open-sleep-seconds"), out int parsedPreOpenSleep) ? parsedPreOpenSleep : 0;
int preStartSleepSeconds = int.TryParse(GetArg(args, "--pre-start-sleep-seconds"), out int parsedPreStartSleep) ? parsedPreStartSleep : 0;
string scenario = GetArg(args, "--scenario") ?? "history";
string retrievalModeName = GetArg(args, "--retrieval-mode") ?? "Full";
bool directConnection = HasFlag(args, "--direct-connection");
bool integratedSecurity = !HasFlag(args, "--no-integrated-security");
string? proxyServer = GetArg(args, "--proxy-server");
string? runtimeMethodPointerOutput = GetArg(args, "--runtime-method-pointer-output");
string runtimeMethodPointerFilters = GetArg(args, "--runtime-method-pointer-filters")
?? "StartDataQuery;StartQuery;GetNextRow;StartEventQuery";
ulong resolutionTicks = ulong.TryParse(GetArg(args, "--resolution-ticks"), out ulong parsedResolutionTicks) ? parsedResolutionTicks : 0;
// Summary-query knobs on HistoryQueryArgs (R1.8/R1.9 capture). Left null/0 = not set,
// so a normal Full read is unaffected. ValueSelector/AggregationType/MaxStates/Filter
// are the native properties that turn a Cyclic/Full query into an analog/state summary.
string? valueSelectorName = GetArg(args, "--value-selector");
string? aggregationTypeName = GetArg(args, "--aggregation-type");
uint maxStates = uint.TryParse(GetArg(args, "--max-states"), out uint parsedMaxStates) ? parsedMaxStates : 0;
string? historyFilter = GetArg(args, "--filter");
DateTime endUtc = TryParseUtc(GetArg(args, "--end-utc")) ?? DateTime.UtcNow;
DateTime startUtc = TryParseUtc(GetArg(args, "--start-utc")) ?? endUtc.AddMinutes(-lookbackMinutes);
string current = Path.GetFullPath(GetArg(args, "--current-dir") ?? Path.Combine(repoRoot, "current"));
string managedDll = Path.GetFullPath(GetArg(args, "--managed-dll-path") ?? Path.Combine(current, "aahClientManaged.dll"));
if (!File.Exists(managedDll))
{
throw new FileNotFoundException("Missing aahClientManaged.dll.", managedDll);
}
if (!Directory.Exists(current))
{
throw new DirectoryNotFoundException($"Missing dependency folder: {current}");
}
AppDomain.CurrentDomain.AssemblyResolve += (_, eventArgs) =>
{
AssemblyName name = new(eventArgs.Name);
string candidate = Path.Combine(current, name.Name + ".dll");
return File.Exists(candidate) ? Assembly.LoadFrom(candidate) : null!;
};
Directory.CreateDirectory(Path.Combine(repoRoot, "docs", "reverse-engineering"));
TryDelete(Path.Combine(repoRoot, "docs", "reverse-engineering", "native-wcf-message-log.svclog"));
TraceSource diagnosticProbe = new("System.ServiceModel");
diagnosticProbe.TraceInformation("NativeTraceHarness diagnostics probe");
diagnosticProbe.Flush();
Directory.SetCurrentDirectory(current);
if (preLoadSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preLoadSleepSeconds));
}
Assembly assembly = Assembly.LoadFrom(managedDll);
string? methodPointerFilter = GetArg(args, "--dump-method-pointers");
if (methodPointerFilter is not null)
{
Console.WriteLine(Serialize(DumpRuntimeMethodPointers(assembly, methodPointerFilter)));
return 0;
}
string? dumpTypeName = GetArg(args, "--dump-type-members");
if (dumpTypeName is not null)
{
Type dumpType = GetType(assembly, dumpTypeName);
if (dumpType.IsEnum)
{
var values = Enum.GetValues(dumpType).Cast<object>()
.Select(v => $"{v} = {Convert.ToInt64(v)}").OrderBy(s => s).ToArray();
Console.WriteLine(Serialize(new { Type = dumpType.FullName, EnumValues = values }));
return 0;
}
BindingFlags df = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
Console.WriteLine(Serialize(new
{
Type = dumpType.FullName,
Properties = dumpType.GetProperties(df).Select(p => $"{p.PropertyType.Name} {p.Name}").OrderBy(s => s).ToArray(),
Fields = dumpType.GetFields(df).Select(f => $"{f.FieldType.Name} {f.Name}").OrderBy(s => s).ToArray(),
Methods = dumpType.GetMethods(df)
.Where(m => !m.IsSpecialName)
.Select(m => $"{m.ReturnType.Name} {m.Name}({string.Join(", ", m.GetParameters().Select(p => p.ParameterType.Name))})")
.OrderBy(s => s).ToArray(),
}));
return 0;
}
Type accessType = GetType(assembly, "ArchestrA.HistorianAccess");
Type connectionArgsType = GetType(assembly, "ArchestrA.HistorianConnectionArgs");
Type connectionStatusType = GetType(assembly, "ArchestrA.HistorianConnectionStatus");
Type connectionType = GetType(assembly, "ArchestrA.HistorianConnectionType");
Type historyQueryArgsType = GetType(assembly, "ArchestrA.HistoryQueryArgs");
Type eventQueryArgsType = GetType(assembly, "ArchestrA.EventQueryArgs");
Type tagQueryArgsType = GetType(assembly, "ArchestrA.TagQueryArgs");
Type eventQueryTypeType = GetType(assembly, "ArchestrA.HistorianEventQueryType");
Type eventOrderType = GetType(assembly, "ArchestrA.HistorianEventOrder");
Type errorType = GetType(assembly, "ArchestrA.HistorianAccessError");
Type retrievalModeType = GetType(assembly, "ArchestrA.HistorianRetrievalMode");
object access = Activator.CreateInstance(accessType)!;
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
SetProperty(connectionArgs, "ServerName", serverName);
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsEventSendScenario(scenario)));
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventConnectionScenario(scenario) ? "Event" : "Process"));
if (directConnection)
{
SetProperty(connectionArgs, "DirectConnection", true);
SetField(connectionArgs, "directConnection", true);
}
if (!string.IsNullOrWhiteSpace(proxyServer))
{
SetProperty(connectionArgs, "ProxyServer", proxyServer!);
}
Dictionary<string, object?> snapshots = [];
if (preOpenSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preOpenSleepSeconds));
}
object openError = Activator.CreateInstance(errorType)!;
MethodInfo openMethod = accessType.GetMethod("OpenConnection", new[] { connectionArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.OpenConnection");
object?[] openArgs = [connectionArgs, openError];
bool openSuccess = (bool)openMethod.Invoke(access, openArgs)!;
openError = openArgs[1]!;
snapshots["ConnectionArgs"] = SnapshotObject(connectionArgs);
snapshots["AccessAfterOpen"] = SnapshotObject(access);
ConnectionStatusSnapshot status = WaitForConnection(access, accessType, connectionStatusType, waitSeconds);
bool startSuccess = false;
object? startError = null;
string? startQueryException = null;
string? moveTerminalDescription = null;
List<object> rows = [];
if (openSuccess && status.ConnectedToServer && IsExecSqlScenario(scenario))
{
// R1.1 capture: drive HistorianAccess.ExecuteSqlCommand(sql, option, out retval,
// out DataTable, out error) so instrument-wcf-{write,read}message can observe the
// Retr.ExeC + Retr.GetR wire shape (handle format, command/option encoding, Retr
// priming, result byte stream). Read-only benign query.
string sql = GetArg(args, "--sql") ?? "SELECT 1 AS ProbeValue";
Type sqlOptionType = GetType(assembly, "ArchestrA.HistorianSqlExecuteOption");
object sqlOption = Enum.Parse(sqlOptionType, GetArg(args, "--sql-option") ?? "ExecuteRecord");
MethodInfo execMethod = accessType.GetMethods()
.First(m => m.Name == "ExecuteSqlCommand" && m.GetParameters().Length == 5);
object?[] execArgs = new object?[] { sql, sqlOption, 0, null, Activator.CreateInstance(errorType) };
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-exec-sql");
bool execOk = (bool)execMethod.Invoke(access, execArgs)!;
int rowCount = -1, colCount = -1;
if (execArgs[3] is System.Data.DataTable table)
{
rowCount = table.Rows.Count;
colCount = table.Columns.Count;
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
Sql = sql,
ExecuteSqlCommandReturned = execOk,
ReturnValue = execArgs[2],
RowCount = rowCount,
ColumnCount = colCount,
Error = SnapshotObject(execArgs[4]!),
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsRuntimeParamScenario(scenario))
{
// R1.2 capture: drive HistorianAccess.GetRuntimeParameter(List<string> names,
// out List<object> results, out error) so instrument-wcf-{write,read}message can
// observe the WCF op name, handle type (uint vs string-handle wall), and the
// btRequest/btResponse buffer format. Pure status read — no write mode needed.
string namesArg = GetArg(args, "--runtime-param-names") ?? "HistorianVersion";
string[] names = namesArg.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
MethodInfo getRtParamMethod = accessType.GetMethods()
.First(m => m.Name == "GetRuntimeParameter" && m.GetParameters().Length == 3);
ParameterInfo[] rtParams = getRtParamMethod.GetParameters();
Type namesListType = rtParams[0].ParameterType; // List<string>
Type resultsListType = rtParams[1].ParameterType.GetElementType()!; // List<...>& -> List<...>
object namesList = Activator.CreateInstance(namesListType)!;
MethodInfo addName = namesListType.GetMethod("Add")!;
foreach (string n in names) addName.Invoke(namesList, new object?[] { n });
object rtError = Activator.CreateInstance(errorType)!;
object?[] rtArgs = new object?[] { namesList, Activator.CreateInstance(resultsListType), rtError };
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-get-runtime-parameter");
bool rtOk = (bool)getRtParamMethod.Invoke(access, rtArgs)!;
object? resultsList = rtArgs[1];
var resultItems = new List<object?>();
if (resultsList is System.Collections.IEnumerable en)
{
foreach (object? item in en)
{
resultItems.Add(new
{
Type = item?.GetType().FullName,
Value = item?.ToString(),
});
}
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
Names = names,
GetRuntimeParameterReturned = rtOk,
ResultsListType = resultsListType.FullName,
Results = resultItems,
Error = SnapshotObject(rtArgs[2]!),
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsHistorianInfoScenario(scenario))
{
// R1.4 capture: drive HistorianAccess.GetHistorianInfo(out HistorianInfo, out error)
// so instrument-wcf-{write,read}message can observe the WCF GETHI pRequestBuff that
// returns the full 518-byte HISTORIAN_INFO struct (version@0 + EventStorageMode@514),
// distinct from the named-value "HistorianVersion" request. Pure status read.
MethodInfo getInfoMethod = accessType.GetMethods()
.First(m => m.Name == "GetHistorianInfo"
&& m.GetParameters().Length == 2
&& m.GetParameters()[0].ParameterType.IsByRef
&& m.GetParameters()[1].ParameterType.IsByRef);
object infoError = Activator.CreateInstance(errorType)!;
object?[] infoArgs = new object?[] { null, infoError };
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-get-historian-info");
bool infoOk = (bool)getInfoMethod.Invoke(access, infoArgs)!;
Console.WriteLine(Serialize(new
{
Scenario = scenario,
GetHistorianInfoReturned = infoOk,
HistorianInfoType = getInfoMethod.GetParameters()[0].ParameterType.GetElementType()?.FullName,
HistorianInfo = infoArgs[0] is null ? null : SnapshotObject(infoArgs[0]!),
Error = SnapshotObject(infoArgs[1]!),
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
{
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
// observe whether the event delivery rides the WCF MDAS path or the storage-engine
// pipe. Gated behind --event-send-confirm because it writes a real (clearly-marked)
// test event into the historian's event history.
if (!HasFlag(args, "--event-send-confirm"))
{
throw new InvalidOperationException(
"Event-send scenario writes a test event to the historian. Pass --event-send-confirm to proceed.");
}
Type historianEventType = GetType(assembly, "ArchestrA.HistorianEvent");
string eventTypeName = GetArg(args, "--event-type") ?? "User.Write";
string eventNamespace = GetArg(args, "--event-namespace") ?? "RetestSdkEventSend";
string eventSource = GetArg(args, "--event-source") ?? "RetestSdkEventSend";
object historianEvent = Activator.CreateInstance(historianEventType)!;
SetProperty(historianEvent, "ID", Guid.NewGuid());
SetProperty(historianEvent, "Type", eventTypeName);
SetProperty(historianEvent, "EventTime", DateTime.UtcNow);
SetProperty(historianEvent, "ReceivedTime", DateTime.UtcNow);
SetProperty(historianEvent, "Namespace", eventNamespace);
AddEventStringProperty(historianEvent, historianEventType, errorType, "Source", eventSource);
AddEventStringProperty(historianEvent, historianEventType, errorType, "TestMarker", "histsdk-R2.1-capture");
snapshots["HistorianEventBeforeSend"] = SnapshotObject(historianEvent);
// AddStreamedValue(HistorianEvent, out HistorianAccessError)
MethodInfo addEventMethod = accessType.GetMethods()
.First(m => m.Name == "AddStreamedValue"
&& m.GetParameters().Length == 2
&& m.GetParameters()[0].ParameterType == historianEventType);
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-event");
object addEventError = Activator.CreateInstance(errorType)!;
object?[] addEventArgs = [historianEvent, addEventError];
bool addEventSuccess = (bool)addEventMethod.Invoke(access, addEventArgs)!;
addEventError = addEventArgs[1]!;
snapshots["AddEventError"] = SnapshotObject(addEventError);
rows.Add(new
{
Kind = "AddStreamedEvent",
Success = addEventSuccess,
Type = eventTypeName,
ErrorType = GetPropertyText(addEventError, "ErrorType"),
ErrorCode = GetPropertyText(addEventError, "ErrorCode"),
ErrorDescription = GetPropertyText(addEventError, "ErrorDescription"),
});
// Force the queued event onto the wire. CloseStorageConnection flushes all memory
// buffers to storage and starts forwarding snapshots.
MethodInfo? closeStorageMethod = accessType.GetMethod("CloseStorageConnection", new[] { errorType.MakeByRefType() });
if (closeStorageMethod is not null)
{
object closeStorageError = Activator.CreateInstance(errorType)!;
object?[] closeStorageArgs = [closeStorageError];
bool closeStorageSuccess = (bool)closeStorageMethod.Invoke(access, closeStorageArgs)!;
closeStorageError = closeStorageArgs[0]!;
rows.Add(new
{
Kind = "CloseStorageConnection",
Success = closeStorageSuccess,
ErrorDescription = GetPropertyText(closeStorageError, "ErrorDescription"),
});
}
// Let the background sender / store-forward flush push bytes before teardown.
int flushWait = int.TryParse(GetArg(args, "--event-send-flush-seconds"), out int fw) ? fw : 6;
if (flushWait > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(flushWait));
}
}
else if (openSuccess && status.ConnectedToServer && IsEventScenario(scenario))
{
object query = accessType.GetMethod("CreateEventQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["EventQueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(eventQueryArgsType)!;
SetProperty(queryArgs, "StartDateTime", startUtc);
SetProperty(queryArgs, "EndDateTime", endUtc);
SetProperty(queryArgs, "EventCount", checked((uint)Math.Max(maxRows, 1)));
SetProperty(queryArgs, "QueryType", Enum.Parse(eventQueryTypeType, "Events"));
SetProperty(queryArgs, "EventOrder", Enum.Parse(eventOrderType, "Ascending"));
snapshots["EventQueryArgsBeforeStart"] = SnapshotObject(queryArgs);
// R1.7 event-filter capture: --event-filter "Property:Op:Value" (repeatable via ';').
// Calls EventQuery.AddEventFilter(name, HistorianComparisionType, value, out err) so the
// filter predicate rides StartEventQuery's request buffer for instrument-wcf capture.
string? eventFilterSpec = GetArg(args, "--event-filter");
if (!string.IsNullOrWhiteSpace(eventFilterSpec))
{
Type comparisonType = GetType(assembly, "ArchestrA.HistorianComparisionType");
MethodInfo addFilterMethod = queryType.GetMethod("AddEventFilter",
new[] { typeof(string), comparisonType, typeof(object), errorType.MakeByRefType() })
?? throw new MissingMethodException("EventQuery.AddEventFilter");
foreach (string clause in eventFilterSpec!.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
{
string[] parts = clause.Split(new[] { ':' }, 3);
if (parts.Length < 3)
{
throw new ArgumentException($"--event-filter clause '{clause}' must be Property:Op:Value.");
}
object filterError = Activator.CreateInstance(errorType)!;
object?[] addFilterArgs = [parts[0], Enum.Parse(comparisonType, parts[1], ignoreCase: true), parts[2], filterError];
object addFilterResult = addFilterMethod.Invoke(query, addFilterArgs)!;
filterError = addFilterArgs[3]!;
rows.Add(new
{
Kind = "AddEventFilter",
Property = parts[0],
Op = parts[1],
Value = parts[2],
FilterId = addFilterResult,
ErrorDescription = GetPropertyText(filterError, "ErrorDescription"),
});
}
snapshots["EventQueryAfterAddFilter"] = SnapshotObject(query);
}
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethod("StartQuery", new[] { eventQueryArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("EventQuery.StartQuery");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-event-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
startError = startArgs[1];
snapshots["EventQueryAfterStart"] = SnapshotObject(query);
snapshots["EventQueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
MethodInfo moveMethod = queryType.GetMethod("MoveNext", new[] { errorType.MakeByRefType() })
?? throw new MissingMethodException("EventQuery.MoveNext");
for (int i = 0; i < maxRows; i++)
{
object moveError = Activator.CreateInstance(errorType)!;
object?[] moveArgs = [moveError];
bool hasRow = (bool)moveMethod.Invoke(query, moveArgs)!;
moveError = moveArgs[0]!;
if (!hasRow)
{
moveTerminalDescription = GetPropertyText(moveError, "ErrorDescription");
break;
}
object result = GetPropertyValue(query, "QueryResult")!;
snapshots["EventQueryAfterFirstMove"] = SnapshotObject(query);
snapshots["EventResultAfterFirstMove"] = SnapshotObject(result);
rows.Add(new
{
EventTime = FormatDateProperty(result, "EventTime"),
ReceivedTime = FormatDateProperty(result, "ReceivedTime"),
EventType = TryGetPropertyValue(result, "EventType"),
Type = TryGetPropertyValue(result, "Type"),
DisplayText = TryGetPropertyValue(result, "DisplayText"),
Area = TryGetPropertyValue(result, "Area"),
Source = TryGetPropertyValue(result, "Source"),
System = TryGetPropertyValue(result, "System"),
Severity = TryGetPropertyValue(result, "Severity"),
Priority = TryGetPropertyValue(result, "Priority"),
IsAlarm = TryGetPropertyValue(result, "IsAlarm")
});
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
else if (openSuccess && status.ConnectedToServer && IsWriteScenario(scenario))
{
// Per docs/plans/write-commands-reverse-engineering.md safety §1, refuse to run
// unless the sandbox tag name is whitelisted.
string sandboxTag = GetArg(args, "--write-sandbox-tag") ?? "RetestSdkWriteSandbox";
if (!sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Write scenario refuses to run against tags whose name doesn't start with 'RetestSdkWrite'. Pass --write-sandbox-tag RetestSdkWriteSandbox.");
}
string writeDataTypeName = GetArg(args, "--write-data-type") ?? "Float";
double writeValue = double.TryParse(GetArg(args, "--write-value"), out double parsedValue) ? parsedValue : 42.5;
double writeMinEu = double.TryParse(GetArg(args, "--write-min-eu"), out double parsedMinEu) ? parsedMinEu : 0.0;
double writeMaxEu = double.TryParse(GetArg(args, "--write-max-eu"), out double parsedMaxEu) ? parsedMaxEu : 100.0;
double writeMinRaw = double.TryParse(GetArg(args, "--write-min-raw"), out double parsedMinRaw) ? parsedMinRaw : 0.0;
double writeMaxRaw = double.TryParse(GetArg(args, "--write-max-raw"), out double parsedMaxRaw) ? parsedMaxRaw : 100.0;
// --write-skip-add-tag lets the value-only second pass run without re-creating
// the sandbox. The connection's tag cache is bound at OpenConnection time, so the
// server-cache refresh after a fresh AddTag requires a NEW process / connection.
bool skipAddTag = HasFlag(args, "--write-skip-add-tag");
bool skipAddValue = HasFlag(args, "--write-skip-add-value");
bool writeApplyScaling = HasFlag(args, "--write-apply-scaling");
string writeStorageTypeName = GetArg(args, "--write-storage-type") ?? "Cyclic";
// Decoded via dnlib — actual enum field types on HistorianTag:
// set_TagDataType stfld ArchestrA.HistorianDataType HistorianTag::dataType
// set_TagStorageType stfld ArchestrA.HistorianStorageType HistorianTag::tagStorageType
Type tagDefType = GetType(assembly, "ArchestrA.HistorianTag");
Type tagDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type tagStorageTypeEnum = GetType(assembly, "ArchestrA.HistorianStorageType");
Type dataValueType = GetType(assembly, "ArchestrA.HistorianDataValue");
Type dataValueDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type connectionIndexEnum = GetType(assembly, "ArchestrA.ConnectionIndex");
// Build HistorianTag for the sandbox.
object tag = Activator.CreateInstance(tagDefType)!;
SetProperty(tag, "TagName", sandboxTag);
SetProperty(tag, "TagDescription", "SDK write-RE sandbox tag");
SetProperty(tag, "EngineeringUnit", "test");
SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, writeDataTypeName, ignoreCase: true));
SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, writeStorageTypeName, ignoreCase: true));
SetProperty(tag, "MinEU", writeMinEu);
SetProperty(tag, "MaxEU", writeMaxEu);
SetProperty(tag, "MinRaw", writeMinRaw);
SetProperty(tag, "MaxRaw", writeMaxRaw);
SetProperty(tag, "StorageRate", 1000u);
SetProperty(tag, "ApplyScaling", writeApplyScaling);
uint tagKey = 0;
if (!skipAddTag)
{
object addError = Activator.CreateInstance(errorType)!;
MethodInfo addTagMethod = accessType.GetMethod("AddTag", new[] { tagDefType, typeof(uint).MakeByRefType(), errorType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.AddTag");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-tag");
object?[] addTagArgs = [tag, tagKey, addError];
bool addTagSuccess = (bool)addTagMethod.Invoke(access, addTagArgs)!;
tagKey = (uint)addTagArgs[1]!;
addError = addTagArgs[2]!;
snapshots["TagAfterAddTag"] = SnapshotObject(tag);
snapshots["AddTagError"] = SnapshotObject(addError);
rows.Add(new
{
Kind = "AddTag",
Success = addTagSuccess,
TagKey = tagKey,
ErrorDescription = GetPropertyText(addError, "ErrorDescription"),
});
}
// ALWAYS look up the real wwTagKey from SQL — AddTag returns a synthetic
// placeholder key (~10000000) when the tag is freshly created, but the server
// session cache only recognizes the real Runtime.dbo.Tag.wwTagKey value
// (small int). Using the synthetic key in AddStreamedValue causes server-side
// error 168 "Tag not added to server".
using (System.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;"))
{
sql.Open();
using System.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT wwTagKey FROM Tag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
object? result = cmd.ExecuteScalar();
if (result is int existingKey)
{
uint realKey = (uint)existingKey;
if (realKey != tagKey)
{
rows.Add(new { Kind = "TagKeyOverride", Synthetic = tagKey, RealFromSql = realKey });
tagKey = realKey;
}
}
using System.Data.SqlClient.SqlCommand analogCmd = sql.CreateCommand();
analogCmd.CommandText = "SELECT MinEU, MaxEU, MinRaw, MaxRaw, Scaling FROM AnalogTag WHERE TagName = @t";
analogCmd.Parameters.AddWithValue("@t", sandboxTag);
using System.Data.SqlClient.SqlDataReader analogReader = analogCmd.ExecuteReader();
if (analogReader.Read())
{
rows.Add(new
{
Kind = "AnalogTagPersisted",
TagName = sandboxTag,
MinEU = analogReader.IsDBNull(0) ? (object)"<null>" : analogReader.GetDouble(0),
MaxEU = analogReader.IsDBNull(1) ? (object)"<null>" : analogReader.GetDouble(1),
MinRaw = analogReader.IsDBNull(2) ? (object)"<null>" : analogReader.GetDouble(2),
MaxRaw = analogReader.IsDBNull(3) ? (object)"<null>" : analogReader.GetDouble(3),
Scaling = analogReader.IsDBNull(4) ? (object)"<null>" : analogReader.GetValue(4),
InputApplyScaling = writeApplyScaling,
});
}
}
// Server cache may not pick up new tags immediately. Allow a wait between AddTag
// and AddStreamedValue so the server side has time to add the new tag to its
// in-memory cache. Configurable via --write-resync-wait-seconds (default 8).
int resyncWait = int.TryParse(GetArg(args, "--write-resync-wait-seconds"), out int w) ? w : 8;
if (resyncWait > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(resyncWait));
}
// Build HistorianDataValue + push it (drives AddS2 on the wire).
if (tagKey != 0 && !skipAddValue)
{
object value = Activator.CreateInstance(dataValueType)!;
SetProperty(value, "TagKey", tagKey);
SetProperty(value, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
SetProperty(value, "OpcQuality", (ushort)192); // Good
SetProperty(value, "Value", writeValue);
SetProperty(value, "StartDateTime", DateTime.UtcNow);
SetProperty(value, "EndDateTime", DateTime.UtcNow);
SetProperty(value, "ApplyScaling", false);
// The public AddStreamedValue overloads (per dnlib) are:
// 0x0600618C — public instance, locals(HistorianDataValue, DateTime)
// 0x0600618D — public instance, no locals (simplest dispatcher)
// The 0x0600618E impl is private and not reachable by reflection.
// Pick the public instance overload whose parameters are
// (HistorianDataValue, [bool/DateTime], HistorianAccessError&) — the
// 0x0600618D dispatcher matches 3 params.
MethodInfo addValueMethod = accessType.GetMethods()
.Where(m => m.Name == "AddStreamedValue")
.OrderBy(m => m.GetParameters().Length)
.First(m =>
{
ParameterInfo[] ps = m.GetParameters();
return ps.Length >= 2 && ps[0].ParameterType == dataValueType
&& ps[ps.Length - 1].ParameterType.IsByRef
&& ps[ps.Length - 1].ParameterType.GetElementType() == errorType;
});
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-value");
object addValueError = Activator.CreateInstance(errorType)!;
ParameterInfo[] addValueParams = addValueMethod.GetParameters();
object?[] addValueArgs = new object?[addValueParams.Length];
addValueArgs[0] = value;
for (int i = 1; i < addValueParams.Length - 1; i++)
{
Type pt = addValueParams[i].ParameterType;
addValueArgs[i] = pt == typeof(bool) ? false
: pt == typeof(DateTime) ? DateTime.UtcNow
: pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
addValueArgs[addValueParams.Length - 1] = addValueError;
bool addValueSuccess = (bool)addValueMethod.Invoke(access, addValueArgs)!;
addValueError = addValueArgs[addValueArgs.Length - 1]!;
snapshots["ValueAfterAddStreamedValue"] = SnapshotObject(value);
snapshots["AddValueError"] = SnapshotObject(addValueError);
rows.Add(new
{
Kind = "AddStreamedValue",
Success = addValueSuccess,
Value = writeValue,
ErrorDescription = GetPropertyText(addValueError, "ErrorDescription"),
});
}
// --write-revision-values triggers the revision (non-streamed) write path.
// Calls SendValues with a HistorianDataValueList populated via
// NonStreamedValuesBegin / AddNonStreamedValue / AddNonStreamedValuesEnd.
// Captures whatever happens — success, server-side rejection, or client
// gate — for protocol decoding purposes.
if (HasFlag(args, "--write-revision-values"))
{
try
{
Type dataValueListType = GetType(assembly, "ArchestrA.HistorianDataValueList");
Type dataCategoryEnum = GetType(assembly, "ArchestrA.HistorianDataCategory");
// Use HistorianAccess.CreateHistorianDataValueList — the public factory that
// binds the list to the live HistorianClient* via GetClient(ConnectionIndex).
MethodInfo createListMethod = accessType.GetMethods()
.Where(m => m.Name == "CreateHistorianDataValueList")
.OrderBy(m => m.GetParameters().Length)
.First();
rows.Add(new
{
Kind = "CreateHistorianDataValueListSig",
Params = createListMethod.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}").ToArray(),
});
rows.Add(new
{
Kind = "EnumValues",
DataCategory = Enum.GetValues(dataCategoryEnum).Cast<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
ConnectionIndex = Enum.GetValues(connectionIndexEnum).Cast<object>().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(),
});
// Pick non-zero values where appropriate. Common AVEVA convention:
// HistorianDataCategory.Real or Process is typically the first non-zero entry
// ConnectionIndex.Process is the first non-zero entry
object?[] createListArgs = createListMethod.GetParameters().Select(p =>
{
if (p.ParameterType == dataCategoryEnum)
{
// Prefer first declared (likely Process or Real)
return Enum.GetValues(dataCategoryEnum).Cast<object>().FirstOrDefault();
}
if (p.ParameterType == connectionIndexEnum)
{
return Enum.Parse(connectionIndexEnum, "Process", ignoreCase: true);
}
return p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null;
}).ToArray();
object listInstance = createListMethod.Invoke(access, createListArgs)!;
snapshots["DataValueListBeforeBegin"] = SnapshotObject(listInstance);
System.Reflection.BindingFlags allInstance =
System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance;
MethodInfo beginMethod = dataValueListType.GetMethods(allInstance)
.First(m => m.Name == "NonStreamedValuesBegin");
object?[] beginArgs = beginMethod.GetParameters()
.Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null)
.ToArray();
object beginResult = beginMethod.Invoke(listInstance, beginArgs)!;
rows.Add(new
{
Kind = "NonStreamedValuesBegin",
Result = beginResult?.ToString(),
ParameterCount = beginArgs.Length,
});
// Optional override: target a different tag (e.g. SysTimeSec) by name.
// Used to investigate whether the cache gate is per-tag (i.e. tags
// already in the runtime cache pass validation while client-created
// sandbox tags don't).
string? targetTagOverride = GetArg(args, "--write-revision-target-tag");
uint revTagKey = tagKey;
if (!string.IsNullOrWhiteSpace(targetTagOverride))
{
using System.Data.SqlClient.SqlConnection sqlT = new("Server=.;Database=Runtime;Integrated Security=SSPI;");
sqlT.Open();
using System.Data.SqlClient.SqlCommand cmdT = sqlT.CreateCommand();
cmdT.CommandText = "SELECT wwTagKey FROM Tag WHERE TagName = @t";
cmdT.Parameters.AddWithValue("@t", targetTagOverride);
object? overrideKey = cmdT.ExecuteScalar();
if (overrideKey is int k)
{
revTagKey = (uint)k;
rows.Add(new { Kind = "RevisionTargetTagOverride", Tag = targetTagOverride, TagKey = revTagKey });
}
else
{
rows.Add(new { Kind = "RevisionTargetTagOverrideNotFound", Tag = targetTagOverride });
}
}
// Build a single HistorianDataValue for the revision sample.
object revValue = Activator.CreateInstance(dataValueType)!;
SetProperty(revValue, "TagKey", revTagKey);
SetProperty(revValue, "DataValueType", Enum.Parse(dataValueDataTypeEnum, "Float", ignoreCase: true));
SetProperty(revValue, "OpcQuality", (ushort)192);
SetProperty(revValue, "Value", writeValue);
SetProperty(revValue, "StartDateTime", DateTime.UtcNow.AddSeconds(-30));
SetProperty(revValue, "EndDateTime", DateTime.UtcNow.AddSeconds(-30));
SetProperty(revValue, "ApplyScaling", false);
MethodInfo[] addMethodCandidates = dataValueListType.GetMethods(allInstance)
.Where(m => m.Name == "AddNonStreamedValue")
.ToArray();
rows.Add(new
{
Kind = "AddNonStreamedValueCandidates",
Count = addMethodCandidates.Length,
Sigs = addMethodCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
MethodInfo addMethod = addMethodCandidates
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValue value, bool validate, HistorianAccessError& error)
// --write-revision-skip-validate flips the bool to false to see if the
// cache gate is enforced inside this function or elsewhere downstream.
bool validateFlag = !HasFlag(args, "--write-revision-skip-validate");
object addError0 = Activator.CreateInstance(errorType)!;
object?[] addArgs = new object?[addMethod.GetParameters().Length];
addArgs[0] = revValue;
for (int i = 1; i < addArgs.Length - 1; i++)
{
Type pt = addMethod.GetParameters()[i].ParameterType;
addArgs[i] = pt == typeof(bool) ? validateFlag : pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
addArgs[addArgs.Length - 1] = addError0;
object addResult = addMethod.Invoke(listInstance, addArgs)!;
object addErrorAfter = addArgs[addArgs.Length - 1]!;
rows.Add(new
{
Kind = "AddNonStreamedValue",
Result = addResult?.ToString(),
ErrorDescription = GetPropertyText(addErrorAfter, "ErrorDescription"),
ErrorCode = GetPropertyText(addErrorAfter, "ErrorCode"),
ErrorType = GetPropertyText(addErrorAfter, "ErrorType"),
});
MethodInfo endListMethod = dataValueListType.GetMethods(allInstance)
.First(m => m.Name == "AddNonStreamedValuesEnd");
object?[] endListArgs = endListMethod.GetParameters()
.Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null)
.ToArray();
object endListResult = endListMethod.Invoke(listInstance, endListArgs)!;
rows.Add(new { Kind = "AddNonStreamedValuesEnd", Result = endListResult?.ToString() });
snapshots["DataValueListBeforeSend"] = SnapshotObject(listInstance);
// Try the DIRECT public AddNonStreamedValue overload on HistorianAccess —
// (ConnectionIndex, HistorianDataValue, bool, ref error). This bypasses
// the DataValueList layer and goes straight to HistorianClient.AddNonStreamedValueAsync.
// If it succeeds where the list path failed, the cache gate is in the list-side
// ValidateValue rather than the native client.
if (HasFlag(args, "--write-revision-direct"))
{
try
{
MethodInfo[] directCandidates = accessType.GetMethods(allInstance)
.Where(m => m.Name == "AddNonStreamedValue")
.ToArray();
rows.Add(new
{
Kind = "DirectAddNonStreamedValueCandidates",
Count = directCandidates.Length,
Sigs = directCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
// Pick the 4-param overload — (ConnectionIndex, HistorianDataValue,
// bool, error&). Drop the IsPublic filter; reflection with
// NonPublic binding flags can call internal methods.
MethodInfo direct = directCandidates.First(m => m.GetParameters().Length == 4);
object directError = Activator.CreateInstance(errorType)!;
object?[] directArgs = new object?[4];
// ConnectionIndex enum values are internal — list with NonPublic
// flags first, then probe both 0 and 1 (most enums use these for
// primary connection slots). For Process scenario it's typically 0.
System.Reflection.FieldInfo[] ciFields = connectionIndexEnum.GetFields(
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
rows.Add(new
{
Kind = "ConnectionIndexFields",
Fields = ciFields.Where(f => f.IsLiteral)
.Select(f => $"{f.Name}={Convert.ToInt32(f.GetRawConstantValue())}").ToArray(),
});
// Default: index 0 (cast int -> enum)
directArgs[0] = Enum.ToObject(connectionIndexEnum, 0);
directArgs[1] = revValue;
directArgs[2] = false; // skip validate
directArgs[3] = directError;
bool directSuccess = (bool)direct.Invoke(access, directArgs)!;
object directErrorAfter = directArgs[3]!;
rows.Add(new
{
Kind = "DirectAddNonStreamedValue",
Success = directSuccess,
ErrorDescription = GetPropertyText(directErrorAfter, "ErrorDescription"),
ErrorCode = GetPropertyText(directErrorAfter, "ErrorCode"),
ErrorType = GetPropertyText(directErrorAfter, "ErrorType"),
});
}
catch (Exception ex)
{
rows.Add(new { Kind = "DirectAddNonStreamedValueException", Type = ex.GetType().Name, Message = ex.Message, Inner = ex.InnerException?.Message });
}
}
// Safety: require explicit --write-revision-commit to actually fire
// SendValues. Without it, the harness validates the path (cache gate,
// value validation) but does NOT push anything to the wire. Important
// when targeting system tags via --write-revision-target-tag.
bool commitRevision = HasFlag(args, "--write-revision-commit");
if (!commitRevision)
{
rows.Add(new { Kind = "RevisionSendValuesSkipped", Reason = "Pass --write-revision-commit to actually call SendValues." });
goto skipSendValues;
}
// SendValues drives the on-the-wire revision flow:
// AddRevisionValuesBegin → AddRevisionValue × N → AddRevisionValuesEnd
// → SendNonStreamedValues (the actual WCF push).
object sendError = Activator.CreateInstance(errorType)!;
MethodInfo[] sendValuesCandidates = accessType.GetMethods(allInstance)
.Where(m => m.Name == "SendValues")
.ToArray();
rows.Add(new
{
Kind = "SendValuesCandidates",
Count = sendValuesCandidates.Length,
Sigs = sendValuesCandidates.Select(m => string.Join(",", m.GetParameters().Select(p => p.ParameterType.Name))).ToArray(),
});
MethodInfo sendValuesMethod = sendValuesCandidates
.OrderBy(m => m.GetParameters().Length)
.First();
// (HistorianDataValueList list, HistorianAccessError& error)
object?[] sendArgs = new object?[sendValuesMethod.GetParameters().Length];
sendArgs[0] = listInstance;
for (int i = 1; i < sendArgs.Length - 1; i++)
{
Type pt = sendValuesMethod.GetParameters()[i].ParameterType;
sendArgs[i] = pt.IsValueType ? Activator.CreateInstance(pt) : null;
}
sendArgs[sendArgs.Length - 1] = sendError;
bool sendSuccess = (bool)sendValuesMethod.Invoke(access, sendArgs)!;
sendError = sendArgs[sendArgs.Length - 1]!;
rows.Add(new
{
Kind = "SendValues",
Success = sendSuccess,
SignatureParamCount = sendArgs.Length,
ErrorDescription = GetPropertyText(sendError, "ErrorDescription"),
ErrorCode = GetPropertyText(sendError, "ErrorCode"),
ErrorType = GetPropertyText(sendError, "ErrorType"),
});
snapshots["SendValuesError"] = SnapshotObject(sendError);
skipSendValues:;
}
catch (Exception ex)
{
rows.Add(new { Kind = "RevisionFlowException", Type = ex.GetType().Name, Message = ex.Message, Inner = ex.InnerException?.Message });
}
}
// DeleteTags runs independently of AddStreamedValue success (write-RE
// sandbox cleanup); guarded by --write-delete-after to keep the default
// run non-destructive.
if (HasFlag(args, "--write-delete-after"))
{
Type tagStatusType = GetType(assembly, "ArchestrA.HistorianTagStatus");
Type tagStatusListType = GetType(assembly, "ArchestrA.HistorianTagStatusList");
object tagsToDelete = Activator.CreateInstance(tagStatusListType)!;
object tagStatus = Activator.CreateInstance(tagStatusType)!;
SetProperty(tagStatus, "TagName", sandboxTag);
MethodInfo addItem = tagStatusListType.GetMethod("Add", new[] { tagStatusType })
?? throw new MissingMethodException("HistorianTagStatusList.Add");
addItem.Invoke(tagsToDelete, [tagStatus]);
object deleteError = Activator.CreateInstance(errorType)!;
MethodInfo deleteMethod = accessType.GetMethods().First(m =>
m.Name == "DeleteTags" && m.GetParameters().Length == 2);
object?[] deleteArgs = [tagsToDelete, deleteError];
bool deleteSuccess = (bool)deleteMethod.Invoke(access, deleteArgs)!;
deleteError = deleteArgs[1]!;
rows.Add(new
{
Kind = "DeleteTags",
Success = deleteSuccess,
ErrorDescription = GetPropertyText(deleteError, "ErrorDescription"),
});
}
}
else if (openSuccess && status.ConnectedToServer && IsTagScenario(scenario))
{
object query = accessType.GetMethod("CreateTagQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["TagQueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(tagQueryArgsType)!;
SetProperty(queryArgs, "TagFilter", tagName);
SetProperty(queryArgs, "CacheTagInfo", true);
SetProperty(queryArgs, "RetrieveTagExtendedPropertyInfo", false);
snapshots["TagQueryArgsBeforeStart"] = SnapshotObject(queryArgs);
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethods().First(method =>
method.Name == "StartQuery" && method.GetParameters().Length == 3);
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-tag-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, 0u, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
uint tagCount = startArgs[1] is uint count ? count : 0;
startError = startArgs[2];
snapshots["TagQueryAfterStart"] = SnapshotObject(query);
snapshots["TagQueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
uint requestedRows = checked((uint)Math.Max(maxRows, 1));
MethodInfo? getTagNamesMethod = queryType.GetMethods().FirstOrDefault(method =>
method.Name == "GetTagNames" && method.GetParameters().Length == 4);
if (getTagNamesMethod is not null)
{
object tagNamesError = Activator.CreateInstance(errorType)!;
object?[] tagNameArgs = [0u, requestedRows, null, tagNamesError];
bool namesSuccess = (bool)getTagNamesMethod.Invoke(query, tagNameArgs)!;
tagNamesError = tagNameArgs[3]!;
rows.Add(new
{
Kind = "TagNames",
Success = namesSuccess,
ErrorDescription = GetPropertyText(tagNamesError, "ErrorDescription"),
Names = ToSerializableValue(tagNameArgs[2])
});
}
MethodInfo? getTagInfoMethod = queryType.GetMethods().FirstOrDefault(method =>
method.Name == "GetTagInfo" && method.GetParameters().Length == 4);
if (getTagInfoMethod is not null)
{
object tagInfoError = Activator.CreateInstance(errorType)!;
object?[] tagInfoArgs = [0u, requestedRows, null, tagInfoError];
bool infoSuccess = (bool)getTagInfoMethod.Invoke(query, tagInfoArgs)!;
tagInfoError = tagInfoArgs[3]!;
rows.Add(new
{
Kind = "TagInfo",
Success = infoSuccess,
ErrorDescription = GetPropertyText(tagInfoError, "ErrorDescription"),
Tags = SummarizeTagList(tagInfoArgs[2])
});
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
else if (openSuccess && status.ConnectedToServer)
{
object query = accessType.GetMethod("CreateHistoryQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty<object>())!;
Type queryType = query.GetType();
snapshots["QueryAfterCreate"] = SnapshotObject(query);
object queryArgs = Activator.CreateInstance(historyQueryArgsType)!;
StringCollection tags = [];
tags.Add(tagName);
SetProperty(queryArgs, "TagNames", tags);
SetProperty(queryArgs, "StartDateTime", startUtc);
SetProperty(queryArgs, "EndDateTime", endUtc);
SetProperty(queryArgs, "BatchSize", checked((uint)Math.Max(maxRows, 1)));
SetProperty(queryArgs, "RetrievalMode", Enum.Parse(retrievalModeType, retrievalModeName, ignoreCase: true));
if (resolutionTicks > 0)
{
SetProperty(queryArgs, "Resolution", resolutionTicks);
}
// Summary knobs — only set when explicitly supplied so plain reads are untouched.
if (valueSelectorName is not null)
{
Type valueSelectorType = GetType(assembly, "ArchestrA.HistorianValueSelector");
SetProperty(queryArgs, "ValueSelector", Enum.Parse(valueSelectorType, valueSelectorName, ignoreCase: true));
}
if (aggregationTypeName is not null)
{
Type aggregationType = GetType(assembly, "ArchestrA.HistorianAggregationType");
SetProperty(queryArgs, "AggregationType", Enum.Parse(aggregationType, aggregationTypeName, ignoreCase: true));
}
if (maxStates > 0)
{
// HistoryQueryArgs.MaxStates is a UInt16 on the native wrapper.
SetProperty(queryArgs, "MaxStates", checked((ushort)maxStates));
}
if (historyFilter is not null)
{
SetProperty(queryArgs, "Filter", historyFilter);
}
snapshots["QueryArgsBeforeStart"] = SnapshotObject(queryArgs);
startError = Activator.CreateInstance(errorType)!;
MethodInfo startMethod = queryType.GetMethod("StartQuery", new[] { historyQueryArgsType, errorType.MakeByRefType() })
?? throw new MissingMethodException("HistoryQuery.StartQuery");
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-history-start");
if (preStartSleepSeconds > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(preStartSleepSeconds));
}
object?[] startArgs = [queryArgs, startError];
try
{
startSuccess = (bool)startMethod.Invoke(query, startArgs)!;
}
catch (TargetInvocationException ex)
{
startQueryException = FormatException(ex.InnerException ?? ex);
}
startError = startArgs[1];
snapshots["QueryAfterStart"] = SnapshotObject(query);
snapshots["QueryArgsAfterStart"] = SnapshotObject(queryArgs);
if (startSuccess)
{
MethodInfo moveMethod = queryType.GetMethod("MoveNext", new[] { errorType.MakeByRefType() })
?? throw new MissingMethodException("HistoryQuery.MoveNext");
for (int i = 0; i < maxRows; i++)
{
object moveError = Activator.CreateInstance(errorType)!;
object?[] moveArgs = [moveError];
bool hasRow = (bool)moveMethod.Invoke(query, moveArgs)!;
moveError = moveArgs[0]!;
if (!hasRow)
{
moveTerminalDescription = GetPropertyText(moveError, "ErrorDescription");
break;
}
object result = GetPropertyValue(query, "QueryResult")!;
snapshots["QueryAfterFirstMove"] = SnapshotObject(query);
snapshots["QueryResultAfterFirstMove"] = SnapshotObject(result);
rows.Add(new
{
StartDateTime = ((DateTime)GetPropertyValue(result, "StartDateTime")!).ToString("O"),
EndDateTime = ((DateTime)GetPropertyValue(result, "EndDateTime")!).ToString("O"),
Quality = GetPropertyValue(result, "Quality"),
OpcQuality = GetPropertyValue(result, "OpcQuality"),
QualityDetail = GetPropertyValue(result, "QualityDetail"),
Value = GetPropertyValue(result, "Value"),
PercentGood = GetPropertyValue(result, "PercentGood")
});
}
}
MethodInfo? endMethod = queryType.GetMethod("EndQuery", new[] { errorType.MakeByRefType() });
if (endMethod is not null)
{
object endError = Activator.CreateInstance(errorType)!;
object?[] endArgs = [endError];
_ = endMethod.Invoke(query, endArgs);
}
if (query is IDisposable disposableQuery)
{
disposableQuery.Dispose();
}
}
if (openSuccess)
{
object closeError = Activator.CreateInstance(errorType)!;
MethodInfo? closeMethod = accessType.GetMethod("CloseConnection", new[] { errorType.MakeByRefType() });
if (closeMethod is not null)
{
object?[] closeArgs = [closeError];
_ = closeMethod.Invoke(access, closeArgs);
}
}
if (access is IDisposable disposableAccess)
{
disposableAccess.Dispose();
}
Trace.Flush();
string tracePath = Path.Combine(repoRoot, "docs", "reverse-engineering", "native-wcf-message-log.svclog");
var output = new
{
Operation = "NativeTraceHarness.IntegratedRead",
Scenario = scenario,
ServerName = serverName,
DirectConnection = directConnection,
ProxyServer = proxyServer,
TagName = tagName,
LookbackMinutes = lookbackMinutes,
RetrievalMode = retrievalModeName,
ResolutionTicks = resolutionTicks,
ValueSelector = valueSelectorName,
AggregationType = aggregationTypeName,
MaxStates = maxStates,
HistoryFilter = historyFilter,
StartUtc = startUtc.ToString("O"),
EndUtc = endUtc.ToString("O"),
OpenSuccess = openSuccess,
OpenErrorType = GetPropertyText(openError, "ErrorType"),
OpenErrorCode = GetPropertyText(openError, "ErrorCode"),
OpenErrorDescription = GetPropertyText(openError, "ErrorDescription"),
status.ConnectedToServer,
status.Pending,
status.ErrorOccurred,
StartQuerySuccess = startSuccess,
StartQueryErrorType = GetPropertyText(startError, "ErrorType"),
StartQueryErrorCode = GetPropertyText(startError, "ErrorCode"),
StartQueryErrorDescription = GetPropertyText(startError, "ErrorDescription"),
StartQueryException = startQueryException,
MoveTerminalDescription = moveTerminalDescription,
RowCount = rows.Count,
Rows = rows,
Snapshots = snapshots,
TracePath = tracePath,
TraceExists = File.Exists(tracePath),
TraceBytes = File.Exists(tracePath) ? new FileInfo(tracePath).Length : 0
};
Console.WriteLine(Serialize(output));
return openSuccess && startSuccess ? 0 : 1;
}
private static ConnectionStatusSnapshot WaitForConnection(object access, Type accessType, Type statusType, int waitSeconds)
{
MethodInfo method = accessType.GetMethod("GetConnectionStatus", new[] { statusType.MakeByRefType() })
?? throw new MissingMethodException("HistorianAccess.GetConnectionStatus");
DateTime deadline = DateTime.UtcNow.AddSeconds(Math.Max(waitSeconds, 1));
ConnectionStatusSnapshot snapshot;
do
{
object status = Activator.CreateInstance(statusType)!;
object?[] args = [status];
_ = method.Invoke(access, args);
status = args[0]!;
snapshot = new ConnectionStatusSnapshot(
(bool)GetPropertyValue(status, "ConnectedToServer")!,
(bool)GetPropertyValue(status, "Pending")!,
(bool)GetPropertyValue(status, "ErrorOccurred")!);
if ((snapshot.ConnectedToServer && !snapshot.Pending) || snapshot.ErrorOccurred || (!snapshot.ConnectedToServer && !snapshot.Pending))
{
return snapshot;
}
Thread.Sleep(250);
} while (DateTime.UtcNow < deadline);
return snapshot;
}
private static IReadOnlyList<object> DumpRuntimeMethodPointers(Assembly assembly, string filter)
{
List<object> results = [];
BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;
LoadedModuleInfo? moduleInfo = FindLoadedModule(Path.GetFileName(assembly.Location));
foreach (MethodInfo method in assembly.ManifestModule.GetMethods(flags))
{
AddRuntimeMethodPointer(results, "<Module>", method, filter, moduleInfo);
}
foreach (Type type in assembly.GetTypes())
{
foreach (MethodInfo method in type.GetMethods(flags))
{
AddRuntimeMethodPointer(results, type.FullName ?? type.Name, method, filter, moduleInfo);
}
}
return results;
}
private static void WriteRuntimeMethodPointerSnapshot(
Assembly assembly,
string? outputPath,
string filtersText,
string repoRoot,
string scenario,
string phase)
{
if (string.IsNullOrWhiteSpace(outputPath))
{
return;
}
string resolvedPath = Path.IsPathRooted(outputPath!)
? outputPath!
: Path.Combine(repoRoot, outputPath!);
string? directory = Path.GetDirectoryName(resolvedPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
List<object> methodPointers = [];
foreach (string rawFilter in filtersText.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries))
{
string filter = rawFilter.Trim();
if (filter.Length == 0)
{
continue;
}
methodPointers.Add(new
{
Filter = filter,
Methods = DumpRuntimeMethodPointers(assembly, filter)
});
}
var snapshot = new
{
Operation = "NativeTraceHarness.RuntimeMethodPointerSnapshot",
ProcessId = Process.GetCurrentProcess().Id,
Scenario = scenario,
Phase = phase,
TimestampUtc = DateTime.UtcNow.ToString("O"),
AssemblyPath = assembly.Location,
MethodPointers = methodPointers
};
File.WriteAllText(resolvedPath, Serialize(snapshot));
}
private static void AddRuntimeMethodPointer(List<object> results, string declaringType, MethodInfo method, string filter, LoadedModuleInfo? moduleInfo)
{
string fullName = declaringType + "." + method.Name;
if (fullName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
{
return;
}
string? pointer = null;
string? prepareError = null;
try
{
RuntimeHelpers.PrepareMethod(method.MethodHandle);
long pointerValue = method.MethodHandle.GetFunctionPointer().ToInt64();
pointer = "0x" + pointerValue.ToString("X");
bool pointerInModule = moduleInfo is not null && pointerValue >= moduleInfo.BaseAddress && pointerValue < moduleInfo.EndAddress;
long? pointerRva = pointerInModule ? pointerValue - moduleInfo!.BaseAddress : null;
results.Add(new
{
DeclaringType = declaringType,
method.Name,
MetadataToken = "0x" + method.MetadataToken.ToString("X8"),
IsStatic = method.IsStatic,
IsPublic = method.IsPublic,
ModuleBase = moduleInfo is not null ? "0x" + moduleInfo.BaseAddress.ToString("X") : null,
ModuleSize = moduleInfo is not null ? "0x" + moduleInfo.Size.ToString("X") : null,
FunctionPointer = pointer,
FunctionPointerInModule = pointerInModule,
FunctionPointerRva = pointerRva.HasValue ? "0x" + pointerRva.Value.ToString("X") : null,
PrepareError = prepareError
});
return;
}
catch (Exception ex)
{
prepareError = FormatException(ex);
}
results.Add(new
{
DeclaringType = declaringType,
method.Name,
MetadataToken = "0x" + method.MetadataToken.ToString("X8"),
IsStatic = method.IsStatic,
IsPublic = method.IsPublic,
ModuleBase = moduleInfo is not null ? "0x" + moduleInfo.BaseAddress.ToString("X") : null,
ModuleSize = moduleInfo is not null ? "0x" + moduleInfo.Size.ToString("X") : null,
FunctionPointer = pointer,
FunctionPointerInModule = false,
FunctionPointerRva = (string?)null,
PrepareError = prepareError
});
}
private static LoadedModuleInfo? FindLoadedModule(string moduleName)
{
foreach (ProcessModule module in Process.GetCurrentProcess().Modules)
{
if (string.Equals(module.ModuleName, moduleName, StringComparison.OrdinalIgnoreCase))
{
return new LoadedModuleInfo(module.BaseAddress.ToInt64(), module.ModuleMemorySize);
}
}
return null;
}
private sealed class LoadedModuleInfo
{
public LoadedModuleInfo(long baseAddress, int size)
{
BaseAddress = baseAddress;
Size = size;
}
public long BaseAddress { get; }
public int Size { get; }
public long EndAddress => BaseAddress + Size;
}
private static string Serialize(object value)
{
return new JavaScriptSerializer { MaxJsonLength = int.MaxValue }.Serialize(value);
}
private static string FindRepoRoot()
{
string? directory = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(directory))
{
if (File.Exists(Path.Combine(directory, "Histsdk.slnx")))
{
return directory!;
}
directory = Directory.GetParent(directory)?.FullName;
}
return Directory.GetCurrentDirectory();
}
private static string? GetArg(string[] args, string name)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
private static bool HasFlag(string[] args, string name)
{
foreach (string arg in args)
{
if (arg.Equals(name, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static DateTime? TryParseUtc(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (!DateTime.TryParse(
value,
null,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out DateTime parsed))
{
throw new ArgumentException("Invalid UTC timestamp: " + value);
}
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
private static Type GetType(Assembly assembly, string name)
{
return assembly.GetType(name, throwOnError: true)!;
}
private static void SetProperty(object target, string name, object value)
{
PropertyInfo? property = target.GetType().GetProperty(name);
if (property is not null && property.CanWrite)
{
MethodInfo? setter = property.GetSetMethod(nonPublic: true);
if (setter is not null)
{
setter.Invoke(target, [value]);
}
}
}
private static void SetField(object target, string name, object value)
{
FieldInfo? field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (field is not null)
{
field.SetValue(target, value);
}
}
private static object? GetPropertyValue(object target, string name)
{
PropertyInfo? property = target.GetType().GetProperty(name);
return property is null || !property.CanRead ? null : property.GetValue(target);
}
private static object? TryGetPropertyValue(object target, string name)
{
return TryRead(() => GetPropertyValue(target, name));
}
private static string? FormatDateProperty(object target, string name)
{
object? value = TryGetPropertyValue(target, name);
return value is DateTime dateTime ? dateTime.ToString("O") : value?.ToString();
}
private static string? GetPropertyText(object? target, string name)
{
if (target is null)
{
return null;
}
return GetPropertyValue(target, name)?.ToString();
}
private static string FormatException(Exception ex)
{
return ex.GetType().Name + ": " + ex.Message;
}
private static bool IsEventScenario(string scenario)
{
return scenario.Equals("event", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("events", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Event-SEND scenario (R2.1 capture): opens an Event connection in write mode
/// (ReadOnly=false) and drives <c>AddStreamedValue(HistorianEvent)</c> so the outgoing
/// event delivery can be captured. Distinct from the read-only event-query scenario.
/// </summary>
private static bool IsEventSendScenario(string scenario)
{
return scenario.Equals("event-send", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("send-event", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Both event-query and event-send require an Event-type connection.</summary>
/// <summary>
/// Runtime-parameter scenario (R1.2 capture): opens a normal authenticated process
/// connection and calls <c>GetRuntimeParameter</c> so the WCF op + buffer format can be
/// captured. Read-only; not an event or write connection.
/// </summary>
private static bool IsRuntimeParamScenario(string scenario)
{
return scenario.Equals("runtime-param", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("runtime-parameter", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Historian-info scenario (R1.4 capture): opens a normal authenticated process connection and
/// calls <c>GetHistorianInfo(out HistorianInfo, out error)</c> so instrument-wcf-{write,read}message
/// can observe the WCF GETHI <c>pRequestBuff</c>/<c>pResponseBuff</c> that returns the full
/// 518-byte HISTORIAN_INFO struct (version@0 + EventStorageMode@514). Pure status read.
/// </summary>
private static bool IsHistorianInfoScenario(string scenario)
{
return scenario.Equals("historian-info", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("hist-info", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("gethi", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// SQL-command scenario (R1.1 capture): opens a normal authenticated process connection and
/// calls <c>ExecuteSqlCommand</c> (Retr.ExeC + Retr.GetR) so the string-handle SQL surface
/// can be captured. Read-only benign query.
/// </summary>
private static bool IsExecSqlScenario(string scenario)
{
return scenario.Equals("exec-sql", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("sql", StringComparison.OrdinalIgnoreCase);
}
private static bool IsEventConnectionScenario(string scenario)
{
return IsEventScenario(scenario) || IsEventSendScenario(scenario);
}
/// <summary>
/// Adds a string property to a HistorianEvent via the public
/// <c>AddProperty(string name, string value, out HistorianAccessError error)</c> overload.
/// </summary>
private static void AddEventStringProperty(object historianEvent, Type historianEventType, Type errorType, string name, string value)
{
MethodInfo addProperty = historianEventType.GetMethods()
.First(m => m.Name == "AddProperty"
&& m.GetParameters().Length == 3
&& m.GetParameters()[0].ParameterType == typeof(string)
&& m.GetParameters()[1].ParameterType == typeof(string)
&& m.GetParameters()[2].ParameterType.IsByRef);
object propertyError = Activator.CreateInstance(errorType)!;
object?[] propertyArgs = [name, value, propertyError];
addProperty.Invoke(historianEvent, propertyArgs);
}
private static bool IsTagScenario(string scenario)
{
return scenario.Equals("tag", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tags", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tag-query", StringComparison.OrdinalIgnoreCase);
}
private static bool IsWriteScenario(string scenario)
{
return scenario.Equals("write", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("writes", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tag-write", StringComparison.OrdinalIgnoreCase);
}
private static Dictionary<string, object?> SnapshotObject(object target)
{
Dictionary<string, object?> snapshot = new(StringComparer.OrdinalIgnoreCase);
BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
foreach (FieldInfo field in target.GetType().GetFields(flags))
{
if (ShouldSkipMember(field.Name))
{
continue;
}
snapshot["field:" + field.Name] = TryRead(() => ToSerializableValue(field.GetValue(target)));
}
foreach (PropertyInfo property in target.GetType().GetProperties(flags))
{
if (!property.CanRead || property.GetIndexParameters().Length != 0 || ShouldSkipMember(property.Name))
{
continue;
}
snapshot["property:" + property.Name] = TryRead(() => ToSerializableValue(property.GetValue(target)));
}
return snapshot;
}
private static bool ShouldSkipMember(string name)
{
return name.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("user", StringComparison.OrdinalIgnoreCase) >= 0
|| name.IndexOf("security", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static object? TryRead(Func<object?> read)
{
try
{
return read();
}
catch (Exception ex)
{
return "<unreadable:" + ex.GetType().Name + ">";
}
}
private static object? ToSerializableValue(object? value)
{
if (value is null)
{
return null;
}
Type type = value.GetType();
if (type.IsEnum)
{
return value.ToString();
}
if (value is DateTime dateTime)
{
return dateTime.ToString("O");
}
if (value is string or bool or byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal)
{
return value;
}
if (value is StringCollection strings)
{
List<string> result = [];
foreach (string? item in strings)
{
if (item is not null)
{
result.Add(item);
}
}
return result;
}
if (value is Array array)
{
List<object?> result = [];
int count = Math.Min(array.Length, 8);
for (int i = 0; i < count; i++)
{
result.Add(ToSerializableValue(array.GetValue(i)));
}
if (array.Length > count)
{
result.Add("<truncated:" + array.Length + ">");
}
return result;
}
return "<" + type.FullName + ">";
}
private static List<object?> SummarizeTagList(object? tagList)
{
List<object?> result = [];
if (tagList is null)
{
return result;
}
object? lengthValue = TryGetPropertyValue(tagList, "Length") ?? TryGetPropertyValue(tagList, "Count");
int length = Convert.ToInt32(lengthValue);
int count = Math.Min(length, 8);
MethodInfo? itemMethod = tagList.GetType().GetMethods().FirstOrDefault(method =>
method.Name == "Item" && method.GetParameters().Length == 1);
for (int i = 0; i < count; i++)
{
object? tag = null;
try
{
tag = itemMethod?.Invoke(tagList, [checked((uint)i)]);
}
catch
{
tag = null;
}
if (tag is null)
{
try
{
tag = itemMethod?.Invoke(tagList, [checked((uint)(i + 1))]);
}
catch
{
tag = null;
}
}
result.Add(tag is null ? null : new
{
TagName = TryGetPropertyValue(tag, "TagName"),
TagDescription = TryGetPropertyValue(tag, "TagDescription"),
EngineeringUnit = TryGetPropertyValue(tag, "EngineeringUnit"),
TagKey = TryGetPropertyValue(tag, "TagKey"),
TagDataType = TryGetPropertyValue(tag, "TagDataType"),
TagStorageType = TryGetPropertyValue(tag, "TagStorageType"),
SourceTag = TryGetPropertyValue(tag, "SourceTag"),
SourceServer = TryGetPropertyValue(tag, "SourceServer"),
IsInActive = TryGetPropertyValue(tag, "IsInActive")
});
}
if (length > count)
{
result.Add("<truncated:" + length + ">");
}
return result;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Best-effort cleanup; WCF may still hold the previous trace file.
}
}
private sealed class ConnectionStatusSnapshot
{
public ConnectionStatusSnapshot(bool connectedToServer, bool pending, bool errorOccurred)
{
ConnectedToServer = connectedToServer;
Pending = pending;
ErrorOccurred = errorOccurred;
}
public bool ConnectedToServer { get; }
public bool Pending { get; }
public bool ErrorOccurred { get; }
}
}