write-commands plan: Phase 2 partial - capture EnsT2(Float) wire bytes
Per plan §1 in scope: EnsT2 for analog tags, AddS2, DelT.
Per plan §2 safety: localhost only, single sandbox tag
RetestSdkWriteSandbox, harness refuses any name not starting with
RetestSdkWrite, time-bounded writes, ReadOnly=false only when scenario
is "write".
Phase 2 actually executed:
1. tools/AVEVA.Historian.NativeTraceHarness/Program.cs extended with
--scenario write. New args:
--write-sandbox-tag <name> (default RetestSdkWriteSandbox)
--write-value <numeric> (default 42.5)
--write-data-type <name> (default Float)
--write-delete-after (best-effort cleanup)
Toggles ConnectionArgs.ReadOnly=false when scenario is "write" so
the connection accepts the write attempt instead of rejecting at
the harness boundary with error 132 "Operation is not enabled".
2. Sandbox tag RetestSdkWriteSandbox created in Runtime DB
(wwTagKey=240, AcquisitionType=2 Manual, StorageType=1 Cyclic)
via the harness's AddTag call. Single dedicated tag per safety §1.
3. Captured the full write-flow wire sequence at
artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/
bothmessage-write-capture-latest.ndjson (46 records, 23 outgoing +
23 incoming).
The chain is identical to the event flow except:
- EnsT2 payload is the 146-byte analog CTagMetadata instead of
the 83-byte event one
- NO RTag2 between Open2 and EnsT2 (events used RTag2 with
CmEventTagId)
4. The 146-byte analog CTagMetadata layout is dumped in the plan doc
for layout decoding. Visible fields (still being aligned against
CTagUtil.ConvertTagMetadataToHistorianTag IL at token 0x060055CE):
- tag name "RetestSdkWriteSandbox" (compact ASCII, len 21)
- 16 bytes of FF (CommonArchestraEventTypeId placeholder unused
for analog?)
- description "SDK write-RE sandbox tag" (compact ASCII, len 24)
- metadata provider "MDAS" (compact ASCII)
- engineering unit "test" (compact ASCII)
- Int64 FILETIME (date-created, year 2026)
- uint32 0x2710 = 10000 (storage-related, possibly StorageRate)
- double 1.0 (likely IntegralDivisor or scaling factor)
- 5-byte trailer FE 00 01 01 01 (matches event tag's
2F 27 01 01 01 shape)
5. AddS2 BLOCKED CLIENT-SIDE at error 168 "Tag not added to server".
Native AddStreamedValue refuses to send because the tag isn't in
the server's session cache, even though EnsT2 created it in the
Runtime DB. Likely needs RTag2(analog tag GUID) prereq similar
to the event flow's RTag2(CmEventTagId), or one of
aahClientCommon.CHistStorage.AddTagidPairs (token 0x0600202F) or
AddTagsWithServerTagId (token 0x06002026). AddS2 wire bytes NOT
captured this session.
6. scripts/decode-write-capture.py — sanitized decoder for the
capture, walks the 46 records and dumps the EnsT2 InBuff bytes
for layout work. No identity strings; only sandbox-chosen values
appear in output.
Phase 2 remaining work documented in the plan doc as a 5-item
checklist for the next session:
1. Decode the AddS2 prereq (likely RTag2 with analog tag GUID).
2. Capture AddS2 wire bytes once prereq is satisfied.
3. Implement HistorianAddTagsProtocol.SerializeAnalog/Discrete/
String CTagMetadata variants.
4. Implement HistorianAddStreamValuesProtocol.Serialize.
5. Implement public surface: EnsureTagAsync, WriteValueAsync,
DeleteTagAsync (golden-byte + gated live integration tests).
No SDK source changed — implementation deferred until AddS2 wire
bytes are in hand.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,7 +93,7 @@ internal static class Program
|
||||
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
|
||||
SetProperty(connectionArgs, "ServerName", serverName);
|
||||
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
|
||||
SetProperty(connectionArgs, "ReadOnly", true);
|
||||
SetProperty(connectionArgs, "ReadOnly", !IsWriteScenario(scenario));
|
||||
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
|
||||
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventScenario(scenario) ? "Event" : "Process"));
|
||||
if (directConnection)
|
||||
@@ -216,6 +216,152 @@ internal static class Program
|
||||
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;
|
||||
|
||||
// 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, "Cyclic", ignoreCase: true));
|
||||
SetProperty(tag, "MinEU", 0.0);
|
||||
SetProperty(tag, "MaxEU", 100.0);
|
||||
SetProperty(tag, "MinRaw", 0.0);
|
||||
SetProperty(tag, "MaxRaw", 100.0);
|
||||
SetProperty(tag, "StorageRate", 1000u);
|
||||
SetProperty(tag, "ApplyScaling", false);
|
||||
|
||||
uint tagKey = 0;
|
||||
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"),
|
||||
});
|
||||
|
||||
// If AddTag returned no key (tag already exists from a prior session), look it up.
|
||||
if (tagKey == 0)
|
||||
{
|
||||
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)
|
||||
{
|
||||
tagKey = (uint)existingKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Build HistorianDataValue + push it (drives AddS2 on the wire).
|
||||
if (tagKey != 0)
|
||||
{
|
||||
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"),
|
||||
});
|
||||
|
||||
// Optionally delete the tag for clean rollback.
|
||||
if (HasFlag(args, "--write-delete-after"))
|
||||
{
|
||||
object deleteError = Activator.CreateInstance(errorType)!;
|
||||
MethodInfo deleteMethod = accessType.GetMethods().FirstOrDefault(m =>
|
||||
m.Name == "DeleteTags" && m.GetParameters().Length == 2)
|
||||
?? throw new MissingMethodException("HistorianAccess.DeleteTags");
|
||||
StringCollection tagsToDelete = [];
|
||||
tagsToDelete.Add(sandboxTag);
|
||||
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>())!;
|
||||
@@ -771,6 +917,13 @@ internal static class Program
|
||||
|| 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);
|
||||
|
||||
Reference in New Issue
Block a user