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; 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; } 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)); SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity); SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventScenario(scenario) ? "Event" : "Process")); if (directConnection) { SetProperty(connectionArgs, "DirectConnection", true); SetField(connectionArgs, "directConnection", true); } if (!string.IsNullOrWhiteSpace(proxyServer)) { SetProperty(connectionArgs, "ProxyServer", proxyServer!); } Dictionary 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 rows = []; if (openSuccess && status.ConnectedToServer && IsEventScenario(scenario)) { object query = accessType.GetMethod("CreateEventQuery", Type.EmptyTypes)!.Invoke(access, Array.Empty())!; 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); 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)"" : analogReader.GetDouble(0), MaxEU = analogReader.IsDBNull(1) ? (object)"" : analogReader.GetDouble(1), MinRaw = analogReader.IsDBNull(2) ? (object)"" : analogReader.GetDouble(2), MaxRaw = analogReader.IsDBNull(3) ? (object)"" : analogReader.GetDouble(3), Scaling = analogReader.IsDBNull(4) ? (object)"" : 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().Select(v => $"{v}={Convert.ToInt32(v)}").ToArray(), ConnectionIndex = Enum.GetValues(connectionIndexEnum).Cast().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().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())!; 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())!; 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); } 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, 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 DumpRuntimeMethodPointers(Assembly assembly, string filter) { List 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, "", 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 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 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); } 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 SnapshotObject(object target) { Dictionary 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 read) { try { return read(); } catch (Exception ex) { return ""; } } 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 result = []; foreach (string? item in strings) { if (item is not null) { result.Add(item); } } return result; } if (value is Array array) { List 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(""); } return result; } return "<" + type.FullName + ">"; } private static List SummarizeTagList(object? tagList) { List 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(""); } 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; } } }