R1.11 AddTagExtendedPropertiesAsync: extended-property write via AddTEx

Adds user-defined extended properties to an existing tag via the 2020 WCF
AddTEx (AddTagExtendedProperties) op. Write-enabled connection + uppercase
storage-session GUID handle; reuses the write orchestrator open/priming chain.

The AddTEx inBuff is the exact inverse of the R1.5 GetTepByNm read-response
framing, so the serializer mirrors the read parser:
  uint32 groupCount + 0x01(group) + [0x09+u16+ASCII tag] + uint32 propCount
  + per prop{ 0x02 + [0x09+u16+ASCII name] + 0x43 VT_BSTR + u16 payloadLen
  + u16 charCount + UTF-16 value } + 0x01(group trailer) + 0x00(terminator).
The trailing 0x00 is required — without it inBuff is one byte short and the
server throws SErrorException in CHistStorage::AddTagExtendedProperties. The
golden fixture pins the clean inBuff the live server accepted (dumped via
AVEVA_HISTORIAN_TEP_DUMP); read-back verified via R1.5. String (0x43) values only.

Delete (DelTep) is deferred: the native DeleteTagExtendedPropertiesByName does a
client-side sync check and returns err 229 for a just-added property, so the
DelTep request never reaches the wire and its inBuff can't be captured yet.

Shipped: HistorianClient.AddTagExtendedPropertiesAsync/AddTagExtendedPropertyAsync;
HistorianTagExtendedPropertyProtocol.SerializeAddRequest; orchestrator path;
golden WcfTagExtendedPropertyWriteProtocolTests (4); gated live write/read-back test;
native-harness `add-tep` scenario + Capture-AddTagExtendedProperties.ps1 +
decode-add-tep-capture.py. Doc: wcf-add-tag-extended-properties.md. 233 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 01:43:19 -04:00
parent 108220c36b
commit 08b950caee
11 changed files with 701 additions and 3 deletions
@@ -131,7 +131,7 @@ internal static class Program
object connectionArgs = Activator.CreateInstance(connectionArgsType)!;
SetProperty(connectionArgs, "ServerName", serverName);
SetProperty(connectionArgs, "TcpPort", checked((ushort)tcpPort));
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsEventSendScenario(scenario)));
SetProperty(connectionArgs, "ReadOnly", !(IsWriteScenario(scenario) || IsEventSendScenario(scenario) || IsAddTagExtendedPropertiesScenario(scenario)));
SetProperty(connectionArgs, "IntegratedSecurity", integratedSecurity);
SetProperty(connectionArgs, "ConnectionType", Enum.Parse(connectionType, IsEventConnectionScenario(scenario) ? "Event" : "Process"));
if (directConnection)
@@ -323,6 +323,120 @@ internal static class Program
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsAddTagExtendedPropertiesScenario(scenario))
{
// R1.11 capture: drive AddTagExtendedProperties(TagExtendedPropertyGroupList, out err)
// — and optionally DeleteTagExtendedPropertiesByName — so instrument-wcf-writemessage can
// observe the AddTEx / DelTep inBuff (tag + property name/value framing). Sandbox-guarded.
string tepTag = GetArg(args, "--tep-tag") ?? "RetestSdkWriteTepTag";
if (!tepTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"add-tep scenario refuses tags that don't start with 'RetestSdkWrite'. Pass --tep-tag RetestSdkWrite...");
}
string propName = GetArg(args, "--tep-name") ?? "SdkTestProp";
string propValue = GetArg(args, "--tep-value") ?? "SdkTestValue";
var tepRows = new List<object>();
// 1) Ensure the tag exists (AddTag) unless --tep-skip-create.
if (!HasFlag(args, "--tep-skip-create"))
{
Type tagDefType = GetType(assembly, "ArchestrA.HistorianTag");
Type tagDataTypeEnum = GetType(assembly, "ArchestrA.HistorianDataType");
Type tagStorageTypeEnum = GetType(assembly, "ArchestrA.HistorianStorageType");
object tag = Activator.CreateInstance(tagDefType)!;
SetProperty(tag, "TagName", tepTag);
SetProperty(tag, "TagDescription", "SDK ext-property write RE sandbox tag");
SetProperty(tag, "EngineeringUnit", "test");
SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, "Float", 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);
object addError = Activator.CreateInstance(errorType)!;
MethodInfo addTagMethod = accessType.GetMethod("AddTag", new[] { tagDefType, typeof(uint).MakeByRefType(), errorType.MakeByRefType() })!;
object?[] addTagArgs = [tag, 0u, addError];
bool addOk = (bool)addTagMethod.Invoke(access, addTagArgs)!;
tepRows.Add(new { Kind = "AddTag", Success = addOk, ErrorDescription = GetPropertyText(addTagArgs[2]!, "ErrorDescription") });
}
// Prime the tag identity (same reason as the read scenario — server-side resolution).
MethodInfo? getTagInfoByName = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "GetTagInfoByName" && m.GetParameters().Length == 4);
if (getTagInfoByName is not null)
{
object tibError = Activator.CreateInstance(errorType)!;
object?[] tibArgs = new object?[] { tepTag, true, null, tibError };
try { getTagInfoByName.Invoke(access, tibArgs); } catch { }
}
// 2) Build TagExtendedPropertyGroupList { TagExtendedPropertyGroup { TagName, [TagExtendedProperty] } }
Type listType = GetType(assembly, "ArchestrA.TagExtendedPropertyGroupList");
Type groupType = GetType(assembly, "ArchestrA.TagExtendedPropertyGroup");
Type propType = GetType(assembly, "ArchestrA.TagExtendedProperty");
Type propDataTypeEnum = GetType(assembly, "ArchestrA.TagExtendedPropertyDataType");
object list = Activator.CreateInstance(listType)!;
object group = Activator.CreateInstance(groupType)!;
SetProperty(group, "TagName", tepTag);
object prop = Activator.CreateInstance(propType)!;
SetProperty(prop, "PropertyName", propName);
SetProperty(prop, "Type", Enum.Parse(propDataTypeEnum, "String", ignoreCase: true));
SetProperty(prop, "Value", propValue);
groupType.GetMethod("Add", new[] { propType })!.Invoke(group, [prop]);
listType.GetMethod("Add", new[] { groupType })!.Invoke(list, [group]);
MethodInfo addTepMethod = accessType.GetMethods()
.First(m => m.Name == "AddTagExtendedProperties" && m.GetParameters().Length == 2);
object addTepError = Activator.CreateInstance(errorType)!;
object?[] addTepArgs = [list, addTepError];
WriteRuntimeMethodPointerSnapshot(assembly, runtimeMethodPointerOutput, runtimeMethodPointerFilters, repoRoot, scenario, "before-add-tep");
bool addTepOk = false;
string? addTepEx = null;
try { addTepOk = (bool)addTepMethod.Invoke(access, addTepArgs)!; }
catch (TargetInvocationException ex) { addTepEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new
{
Kind = "AddTagExtendedProperties",
Success = addTepOk,
Exception = addTepEx,
ErrorDescription = GetPropertyText(addTepArgs[1]!, "ErrorDescription"),
ErrorCode = GetPropertyText(addTepArgs[1]!, "ErrorCode"),
});
// 3) Optional delete (DelTep) to capture its inBuff too.
if (HasFlag(args, "--tep-delete"))
{
MethodInfo? delTepMethod = accessType.GetMethods()
.FirstOrDefault(m => m.Name == "DeleteTagExtendedPropertiesByName" && m.GetParameters().Length == 4);
if (delTepMethod is not null)
{
Type namesColType = delTepMethod.GetParameters()[1].ParameterType; // StringCollection
object names = Activator.CreateInstance(namesColType)!;
namesColType.GetMethod("Add", new[] { typeof(string) })!.Invoke(names, [propName]);
object delErr = Activator.CreateInstance(errorType)!;
object?[] delArgs = [tepTag, names, true, delErr];
bool delOk = false; string? delEx = null;
try { delOk = (bool)delTepMethod.Invoke(access, delArgs)!; }
catch (TargetInvocationException ex) { delEx = FormatException(ex.InnerException ?? ex); }
tepRows.Add(new { Kind = "DeleteTagExtendedPropertiesByName", Success = delOk, Exception = delEx, ErrorDescription = GetPropertyText(delArgs[3]!, "ErrorDescription") });
}
}
Console.WriteLine(Serialize(new
{
Scenario = scenario,
TepTag = tepTag,
PropertyName = propName,
PropertyValue = propValue,
Rows = tepRows,
}));
return 0;
}
else if (openSuccess && status.ConnectedToServer && IsEventSendScenario(scenario))
{
// R2.1 capture: drive AddStreamedValue(HistorianEvent) and let instrument-wcf-*
@@ -1638,6 +1752,19 @@ internal static class Program
|| scenario.Equals("tag-tep", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Extended-property WRITE scenario (R1.11 capture): opens a write-enabled connection and calls
/// <c>AddTagExtendedProperties(TagExtendedPropertyGroupList, out err)</c> (and optionally
/// <c>DeleteTagExtendedPropertiesByName</c>) so instrument-wcf-writemessage can observe the
/// <c>AddTEx</c>/<c>DelTep</c> inBuff. Sandbox-guarded: the tag must start with RetestSdkWrite.
/// </summary>
private static bool IsAddTagExtendedPropertiesScenario(string scenario)
{
return scenario.Equals("add-tep", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("tep-write", StringComparison.OrdinalIgnoreCase)
|| scenario.Equals("extended-property-write", StringComparison.OrdinalIgnoreCase);
}
private static bool IsEventConnectionScenario(string scenario)
{
return IsEventScenario(scenario) || IsEventSendScenario(scenario);