Implement EnsureTagAsync (live-verified) + DeleteTagAsync (DelT semantics partial)
New SDK surface:
HistorianClient.EnsureTagAsync(HistorianTagDefinition)
HistorianClient.DeleteTagAsync(string tagName)
Plumbing:
src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
Public input model — TagName/Description/EngineeringUnit/DataType/MinEU/MaxEU.
Currently only HistorianDataType.Float is live-verified.
src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
SerializeAnalogCTagMetadata produces 146-byte payload byte-for-byte
identical to the captured native EnsT2(Float) request.
SerializeDeleteTagNames produces ushort 0x6751 + ushort 1 + uint count
+ per-tag (uint charCount + UTF-16 chars).
src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
Both EnsT2 and DelT run the full Stat-priming chain captured for the
analog flow (UpdC3 + Stat.GetV ×3 + Stat.GETHI ×2 + 7× GetSystemParameter
+ Trx.GetV + Retr.GetV).
src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs
MapDataType extended to accept tag-origin marker 0xC7 (SDK-created tags).
Tests:
5 golden-byte tests (HistorianTagWriteProtocolTests):
SerializeAnalogCTagMetadata byte-for-byte match against captured 146-byte fixture
SerializeAnalogCTagMetadata produces different bytes for different inputs
SerializeDeleteTagNames single-tag matches captured shape
SerializeDeleteTagNames multi-tag appends each
SerializeDeleteTagNames empty list throws
1 live integration test (gated by HISTORIAN_WRITE_SANDBOX_TAG):
EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian
EnsureTagAsync creates the sandbox tag, GetTagMetadataAsync reads it
back. 130/130 tests pass.
Harness improvements:
--write-delete-after now runs DelT independently of AddStreamedValue
outcome.
HistorianTagStatusList constructed correctly for DeleteTags reflection
call (previous StringCollection attempt failed with TypeMismatch).
Known DelT gap: SDK's DeleteTagAsync returns true but server-side
cascading deletion does not always complete (the row remains in
Runtime.dbo.Tag). The captured native flow's DelT removes the tag
cleanly (verified via harness --write-delete-after), so something
around the WCF DelT call is missing from our orchestrator. Documented
as known issue with SMC-based cleanup as workaround.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -301,6 +301,62 @@ public sealed class HistorianClientIntegrationTests
|
||||
Assert.NotNull(metadata.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian()
|
||||
{
|
||||
// Per docs/plans/write-commands-reverse-engineering.md safety rules: localhost only,
|
||||
// sandbox tag name must start with "RetestSdkWrite", tag is created if missing and
|
||||
// always deleted at the end so the test leaves zero residue.
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(sandboxTag) || !sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
|
||||
{
|
||||
return; // safety gate per the plan
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "SDK live integration test sandbox",
|
||||
EngineeringUnit = "test",
|
||||
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
||||
MinEU = 0.0,
|
||||
MaxEU = 100.0,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// EnsureTags2 returns true on fresh creation, false on "already exists with same
|
||||
// metadata" (per the captured event-flow analog). The success criterion is the
|
||||
// tag being PRESENT in the DB after the call, not the return value.
|
||||
_ = await client.EnsureTagAsync(definition, CancellationToken.None);
|
||||
|
||||
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
|
||||
await client.GetTagMetadataAsync(sandboxTag, CancellationToken.None);
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(sandboxTag, metadata.Name);
|
||||
Assert.Equal(AVEVA.Historian.Client.Models.HistorianDataType.Float, metadata.DataType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup attempt — DeleteTags semantics still under investigation per the
|
||||
// write-commands plan; even when DelT returns true the deletion may be
|
||||
// asynchronous on the server. Don't assert post-delete state.
|
||||
try { _ = await client.DeleteTagAsync(sandboxTag, CancellationToken.None); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
public sealed class HistorianTagWriteProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_MatchesCapturedNativeBytesByteForByte()
|
||||
{
|
||||
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes from
|
||||
// artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/
|
||||
// fresh-enst2-latest.ndjson — 146 bytes. Inputs:
|
||||
// tagName = "RetestSdkWriteSandbox" (the sandbox)
|
||||
// description = "SDK write-RE sandbox tag"
|
||||
// eu = "test"
|
||||
// FILETIME = 0x01DCDBBFCD87D049 (captured at run time)
|
||||
const string ExpectedHex =
|
||||
"6703000100000004C6020100000000000000000000000000000000" +
|
||||
"09150052657465737453646B577269746553616E64626F78" +
|
||||
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" +
|
||||
"09180053444B2077726974652D52452073616E64626F78207461" +
|
||||
"670904004D44415302010100000001E803000049D087CDBFDBDC01" +
|
||||
"1A030904007465737410270000000000000000F03FFE00010101";
|
||||
|
||||
byte[] expected = Convert.FromHexString(ExpectedHex);
|
||||
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteSandbox",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01DCDBBFCD87D049L));
|
||||
|
||||
Assert.Equal(146, expected.Length);
|
||||
Assert.Equal(146, actual.Length);
|
||||
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_DifferentInputsProducesDifferentBytesInExpectedSlots()
|
||||
{
|
||||
DateTime t = new(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
|
||||
byte[] a = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag1", "DescA", "uA", t);
|
||||
byte[] b = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag2", "DescB", "uB", t);
|
||||
Assert.NotEqual(Convert.ToHexString(a), Convert.ToHexString(b));
|
||||
// First difference must be inside the tagName region (offset 27+ after the 9-byte
|
||||
// header + 16-byte zero block + 2-byte compact-ASCII len-prefix).
|
||||
int firstDiff = 0;
|
||||
while (firstDiff < a.Length && a[firstDiff] == b[firstDiff]) firstDiff++;
|
||||
Assert.InRange(firstDiff, 25, a.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeleteTagNames_SingleTagMatchesCapturedShape()
|
||||
{
|
||||
// Captured DelT.tagNames bytes for ['RetestSdkWriteSandbox']:
|
||||
// ushort 0x6751 + ushort 1 + uint32 1 + uint32 21 + UTF-16 "RetestSdkWriteSandbox"
|
||||
// = 12-byte header + 42-byte UTF-16 string = 54 bytes total.
|
||||
byte[] expected = Concat(
|
||||
[0x51, 0x67, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00],
|
||||
Encoding.Unicode.GetBytes("RetestSdkWriteSandbox"));
|
||||
|
||||
byte[] actual = HistorianTagWriteProtocol.SerializeDeleteTagNames(["RetestSdkWriteSandbox"]);
|
||||
|
||||
Assert.Equal(54, actual.Length);
|
||||
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeleteTagNames_MultipleTagsAppendsEach()
|
||||
{
|
||||
byte[] result = HistorianTagWriteProtocol.SerializeDeleteTagNames(["A", "BB", "CCC"]);
|
||||
// 8-byte header (ushort 0x6751 + ushort 1 + uint32 tagCount)
|
||||
// + 3 × (uint32 charCount + UTF-16 chars)
|
||||
// = 8 + (4 + 2) + (4 + 4) + (4 + 6) = 32 bytes
|
||||
Assert.Equal(32, result.Length);
|
||||
// Header: 0x6751 + 0x0001 + count=3
|
||||
Assert.Equal(0x51, result[0]); Assert.Equal(0x67, result[1]);
|
||||
Assert.Equal(0x01, result[2]); Assert.Equal(0x00, result[3]);
|
||||
Assert.Equal(3, BitConverter.ToInt32(result, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeDeleteTagNames_EmptyListThrows()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => HistorianTagWriteProtocol.SerializeDeleteTagNames([]));
|
||||
}
|
||||
|
||||
private static byte[] Concat(params byte[][] arrays)
|
||||
{
|
||||
int total = 0; foreach (byte[] a in arrays) total += a.Length;
|
||||
byte[] result = new byte[total]; int off = 0;
|
||||
foreach (byte[] a in arrays) { Buffer.BlockCopy(a, 0, result, off, a.Length); off += a.Length; }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user