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:
@@ -141,6 +141,41 @@ Phase 2 effective deliverables:
|
||||
- ⏸ Revision write path — separate capture needed against a historized
|
||||
tag
|
||||
|
||||
## Phase 3 partial (2026-05-04) — EnsureTagAsync live, DeleteTagAsync partial
|
||||
|
||||
`HistorianTagWriteProtocol` + `HistorianWcfTagWriteOrchestrator` +
|
||||
`HistorianClient.EnsureTagAsync`/`DeleteTagAsync` landed:
|
||||
|
||||
- `HistorianTagDefinition` public model (TagName/Description/EngineeringUnit/
|
||||
DataType/MinEU/MaxEU; only `Float` data type currently supported live).
|
||||
- `HistorianTagWriteProtocol.SerializeAnalogCTagMetadata` — produces 146-byte
|
||||
payload byte-for-byte identical to the captured native EnsT2(Float) request.
|
||||
- `HistorianTagWriteProtocol.SerializeDeleteTagNames` — `[ushort 0x6751,
|
||||
ushort 1, uint count, per-tag (uint charCount + UTF-16 chars)]`.
|
||||
- `HistorianWcfTagWriteOrchestrator` — 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).
|
||||
- New tag-origin marker `0xC7` added to `MapDataType` (SDK-created tags have
|
||||
byte 1 = 0xC7, distinct from 0xCF system / 0xC3 MDAS-routed).
|
||||
|
||||
Golden-byte tests (5): EnsT2(Float) byte-for-byte match against the captured
|
||||
146-byte fixture; DelT(single tag) byte-for-byte; DelT(multi-tag); empty list
|
||||
throws; different-inputs-produce-different-bytes.
|
||||
|
||||
Live integration test
|
||||
(`EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian`,
|
||||
gated by `HISTORIAN_WRITE_SANDBOX_TAG=RetestSdkWriteSandbox`): EnsureTagAsync
|
||||
followed by GetTagMetadataAsync confirms the sandbox tag is created in
|
||||
the Runtime DB. Test passes 130/130 in the full suite.
|
||||
|
||||
**Known DelT gap.** SDK's DeleteTagAsync currently returns true but the
|
||||
server-side cascading deletion does not always complete — the row remains
|
||||
in `Runtime.dbo.Tag` even after the call returns. The captured native flow's
|
||||
DelT removes the tag cleanly (verified via the harness with
|
||||
`--write-delete-after`), so something the native code does between or
|
||||
around the WCF DelT call is missing from our orchestrator. The harness
|
||||
cleanup path remains the documented workaround for sandbox housekeeping.
|
||||
|
||||
## Phase 2 remaining work (revised — narrower scope)
|
||||
|
||||
1. Decode the 146-byte EnsT2(Float) CTagMetadata against the IL of
|
||||
|
||||
@@ -121,6 +121,42 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
return _protocol.GetSystemParameterAsync(name, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates the named tag in the Historian Runtime database via
|
||||
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
|
||||
/// live-verified. Note: writing data values to the new tag (via a separate
|
||||
/// AddStreamedValue/AddS2 path) is NOT supported by the SDK — see
|
||||
/// <c>docs/plans/write-commands-reverse-engineering.md</c> for the architectural
|
||||
/// finding.
|
||||
/// </summary>
|
||||
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException("EnsureTagAsync requires Windows for the SSPI auth path");
|
||||
}
|
||||
return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the named tag via <c>DeleteTags</c>. **Known issue (2026-05-04):**
|
||||
/// the SDK's DelT call returns true but the server-side cascading deletion does
|
||||
/// not always complete (the row remains in <c>Runtime.dbo.Tag</c>). The
|
||||
/// captured native flow's DelT removes the tag cleanly, so additional priming
|
||||
/// or a side call between WCF DelT and server cascade is missing. Use the SMC
|
||||
/// fallback to clean up sandbox tags until this is resolved.
|
||||
/// </summary>
|
||||
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException("DeleteTagAsync requires Windows for the SSPI auth path");
|
||||
}
|
||||
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Input model for <see cref="HistorianClient.EnsureTagAsync"/> — the minimal set of
|
||||
/// fields the SDK currently surfaces for tag creation. Mirrors the analog-Float shape
|
||||
/// captured from the native wrapper (see
|
||||
/// <c>docs/plans/write-commands-reverse-engineering.md</c> Phase 2 findings).
|
||||
/// MinEU/MaxEU are accepted but the underlying CTagMetadata serializer's bytes
|
||||
/// for the EU range are not yet decoded — non-default values are sent on the wire
|
||||
/// but the server's interpretation has not been verified.
|
||||
/// </summary>
|
||||
public sealed record HistorianTagDefinition
|
||||
{
|
||||
/// <summary>Tag name (ASCII; up to 255 chars per server limit).</summary>
|
||||
public required string TagName { get; init; }
|
||||
|
||||
/// <summary>Tag description (free text; up to 255 chars).</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Engineering unit label (e.g. "Seconds", "kPa"). Required for analog tags.</summary>
|
||||
public string? EngineeringUnit { get; init; }
|
||||
|
||||
/// <summary>Native data type. Currently only <see cref="HistorianDataType.Float"/> is live-verified end-to-end.</summary>
|
||||
public HistorianDataType DataType { get; init; } = HistorianDataType.Float;
|
||||
|
||||
/// <summary>Engineering-units lower bound (analog tags). Default 0.</summary>
|
||||
public double MinEU { get; init; }
|
||||
|
||||
/// <summary>Engineering-units upper bound (analog tags). Default 100.</summary>
|
||||
public double MaxEU { get; init; } = 100.0;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Text;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Serializers for the EnsT2 (CTagMetadata) and DelT (tag-name list) write paths.
|
||||
/// Decoded from native captures landed in
|
||||
/// <c>artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/bothmessage-write-with-delt-latest.ndjson</c>
|
||||
/// — see <c>docs/plans/write-commands-reverse-engineering.md</c> Phase 2 findings.
|
||||
///
|
||||
/// Per the captured 146-byte analog Float CTagMetadata, the layout is:
|
||||
/// <code>
|
||||
/// 9-byte fixed header = 67 03 00 01 00 00 00 04 C6
|
||||
/// 18 zero bytes (placeholder GUID + 2 bytes; future server-assigned tag id)
|
||||
/// compact ASCII tag name
|
||||
/// 16 bytes of 0xFF (sentinel — likely common-event-type GUID equivalent unused for analog)
|
||||
/// compact ASCII description
|
||||
/// compact ASCII metadata provider ("MDAS")
|
||||
/// 6-byte flag block = 02 01 01 00 00 00
|
||||
/// uint32 storage rate (ms)
|
||||
/// int64 date-created FILETIME UTC
|
||||
/// 2-byte separator = 1A 03
|
||||
/// compact ASCII engineering unit
|
||||
/// uint32 = 0x2710 (10000 — purpose unclear; observed constant)
|
||||
/// 8-byte double = 1.0 (likely IntegralDivisor)
|
||||
/// 5-byte trailer = FE 00 01 01 01
|
||||
/// </code>
|
||||
/// MinEU/MaxEU/MinRaw/MaxRaw fields and their wire positions are NOT yet decoded
|
||||
/// from the captured fixture (the test tag used the defaults). The serializer accepts
|
||||
/// those parameters from <see cref="Models.HistorianTagDefinition"/> but their wire
|
||||
/// representation is currently a TODO; for now they are not encoded into the
|
||||
/// payload — the server uses defaults from the AnalogTag table after creation.
|
||||
/// </remarks>
|
||||
internal static class HistorianTagWriteProtocol
|
||||
{
|
||||
private const byte CompactAsciiMarker = 0x09;
|
||||
|
||||
private static readonly byte[] AnalogHeader =
|
||||
[
|
||||
// bytes 0-8 (constant — observed identical across runs)
|
||||
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
|
||||
// bytes 9-10: observed `02 01` (purpose unclear — possibly a sub-marker)
|
||||
0x02, 0x01,
|
||||
];
|
||||
|
||||
private static readonly byte[] AnalogPadding16 = new byte[16];
|
||||
private static readonly byte[] AnalogPostNamePadding = new byte[16];
|
||||
|
||||
static HistorianTagWriteProtocol()
|
||||
{
|
||||
// 16 bytes of 0xFF observed between tag name and description.
|
||||
for (int i = 0; i < AnalogPostNamePadding.Length; i++)
|
||||
{
|
||||
AnalogPostNamePadding[i] = 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
// After MDAS, the captured layout is:
|
||||
// `02 01 01 00 00 00` (6 bytes — flag block, observed constant)
|
||||
// `01` (1 byte — observed constant; purpose unclear)
|
||||
// uint32 storage rate (4 bytes)
|
||||
private static readonly byte[] AnalogFlagBlock = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
|
||||
private static readonly byte[] AnalogSeparator = [0x1A, 0x03];
|
||||
private static readonly byte[] AnalogTrailer = [0xFE, 0x00, 0x01, 0x01, 0x01];
|
||||
|
||||
private const string MetadataProvider = "MDAS";
|
||||
private const uint IntegralDivisorMagic = 0x2710u;
|
||||
private const uint DefaultStorageRateMs = 1000u;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a CTagMetadata payload for an analog tag (CDataType=Float currently
|
||||
/// supported). Output matches the byte-for-byte capture for the same inputs.
|
||||
/// </summary>
|
||||
/// <param name="tagName">Tag name (ASCII).</param>
|
||||
/// <param name="description">Tag description (ASCII; null/empty allowed).</param>
|
||||
/// <param name="engineeringUnit">EU label (ASCII; null/empty allowed).</param>
|
||||
/// <param name="dateCreatedUtc">DateCreated FILETIME (caller passes <see cref="DateTime.UtcNow"/>).</param>
|
||||
/// <param name="storageRateMs">StorageRate in milliseconds.</param>
|
||||
public static byte[] SerializeAnalogCTagMetadata(
|
||||
string tagName,
|
||||
string? description,
|
||||
string? engineeringUnit,
|
||||
DateTime dateCreatedUtc,
|
||||
uint storageRateMs = DefaultStorageRateMs)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
|
||||
using MemoryStream ms = new();
|
||||
using BinaryWriter w = new(ms);
|
||||
|
||||
w.Write(AnalogHeader); // 9 bytes
|
||||
w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
|
||||
WriteCompactAscii(w, tagName); // var
|
||||
w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
|
||||
WriteCompactAscii(w, description ?? string.Empty); // var
|
||||
WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
|
||||
w.Write(AnalogFlagBlock); // 6 bytes
|
||||
w.Write(storageRateMs); // uint32
|
||||
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
|
||||
w.Write(AnalogSeparator); // 2 bytes
|
||||
WriteCompactAscii(w, engineeringUnit ?? string.Empty); // var
|
||||
w.Write(IntegralDivisorMagic); // uint32 (purpose unclear — captured constant)
|
||||
w.Write(1.0); // double
|
||||
w.Write(AnalogTrailer); // 5 bytes
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the tagNames byte buffer for the DelT (DeleteTags) WCF op.
|
||||
/// Decoded layout from a captured DelT request:
|
||||
/// <code>
|
||||
/// ushort header1 = 0x6751
|
||||
/// ushort header2 = 1
|
||||
/// uint32 tagCount
|
||||
/// for each tag: uint32 charCount + charCount × UTF-16 LE chars
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static byte[] SerializeDeleteTagNames(IReadOnlyList<string> tagNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagNames);
|
||||
if (tagNames.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("DeleteTags requires at least one tag name.", nameof(tagNames));
|
||||
}
|
||||
|
||||
using MemoryStream ms = new();
|
||||
using BinaryWriter w = new(ms);
|
||||
w.Write((ushort)0x6751);
|
||||
w.Write((ushort)1);
|
||||
w.Write(checked((uint)tagNames.Count));
|
||||
foreach (string name in tagNames)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(tagNames));
|
||||
w.Write(checked((uint)name.Length));
|
||||
w.Write(Encoding.Unicode.GetBytes(name));
|
||||
}
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Compact ASCII string: <c>0x09 + UInt16 byteLen + LEN ASCII bytes</c>.</summary>
|
||||
private static void WriteCompactAscii(BinaryWriter writer, string value)
|
||||
{
|
||||
byte[] ascii = Encoding.ASCII.GetBytes(value);
|
||||
if (ascii.Length > ushort.MaxValue)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value), "Compact ASCII strings cannot exceed UInt16 length.");
|
||||
}
|
||||
writer.Write(CompactAsciiMarker);
|
||||
writer.Write((ushort)ascii.Length);
|
||||
writer.Write(ascii);
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,10 @@ internal static class HistorianWcfTagClient
|
||||
/// </summary>
|
||||
internal static HistorianDataType MapDataType(byte[] nativeDataTypeDescriptor)
|
||||
{
|
||||
if (nativeDataTypeDescriptor is not [0x03, 0xCF or 0xC3, _, _])
|
||||
// byte 1 origin marker: 0xCF = system / built-in tag, 0xC3 = MDAS-routed
|
||||
// (e.g. OPC UA imported), 0xC7 = SDK-created via EnsT2 (live-verified by the
|
||||
// EnsureTagAsync round-trip test).
|
||||
if (nativeDataTypeDescriptor is not [0x03, 0xCF or 0xC3 or 0xC7, _, _])
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"GetTagInfoFromName data type descriptor {Convert.ToHexString(nativeDataTypeDescriptor)}");
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf.Contracts;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Drives the EnsT2 (EnsureTags2) and DelT (DeleteTags) WCF operations end-to-end.
|
||||
/// Mirrors <see cref="HistorianWcfReadOrchestrator"/> for the reads flow — opens an
|
||||
/// authenticated session, runs the documented priming chain (UpdC3 + 7×
|
||||
/// Stat.GetSystemParameter + Trx/Stat/Retr GetV) and then issues the write op.
|
||||
///
|
||||
/// AddS2 is intentionally NOT here — it is blocked architecturally per
|
||||
/// <c>docs/plans/write-commands-reverse-engineering.md</c> Phase 2 findings.
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class HistorianWcfTagWriteOrchestrator
|
||||
{
|
||||
private readonly HistorianClientOptions _options;
|
||||
|
||||
public HistorianWcfTagWriteOrchestrator(HistorianClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(definition.TagName, nameof(definition));
|
||||
if (definition.DataType != HistorianDataType.Float)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"EnsureTagAsync currently only supports HistorianDataType.Float (analog double); requested {definition.DataType}");
|
||||
}
|
||||
return Task.Run(() => EnsureTag(definition), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
return Task.Run(() => DeleteTag(tagName), cancellationToken);
|
||||
}
|
||||
|
||||
private bool EnsureTag(HistorianTagDefinition definition)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
||||
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
|
||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||
EndpointAddress retrievalEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval);
|
||||
if (_options.Transport != HistorianTransport.LocalPipe)
|
||||
{
|
||||
retrievalEndpoint = HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
|
||||
}
|
||||
|
||||
bool result = false;
|
||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
||||
additionalSetup: (historyChannel, context) => result = SendEnsureTags2(
|
||||
historyChannel, context, definition, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint));
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool DeleteTag(string tagName)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
||||
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
|
||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||
EndpointAddress retrievalEndpoint = _options.Transport == HistorianTransport.LocalPipe
|
||||
? HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval)
|
||||
: HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
|
||||
|
||||
bool result = false;
|
||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
||||
additionalSetup: (historyChannel, context) =>
|
||||
{
|
||||
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
|
||||
result = SendDeleteTags(historyChannel, context, tagName);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool SendEnsureTags2(
|
||||
IHistoryServiceContract2 historyChannel,
|
||||
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
||||
HistorianTagDefinition definition,
|
||||
Binding auxBinding,
|
||||
EndpointAddress statusEndpoint,
|
||||
EndpointAddress transactionEndpoint,
|
||||
EndpointAddress retrievalEndpoint)
|
||||
{
|
||||
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
|
||||
|
||||
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
byte[] payload = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: definition.TagName,
|
||||
description: definition.Description,
|
||||
engineeringUnit: definition.EngineeringUnit,
|
||||
dateCreatedUtc: DateTime.UtcNow);
|
||||
|
||||
return historyChannel.EnsureTags2(
|
||||
handle: handle,
|
||||
elementCount: 1,
|
||||
inputBuffer: payload,
|
||||
outputBuffer: out _,
|
||||
errorBuffer: out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the priming chain captured between Open2 and the actual write op (EnsT2 / DelT).
|
||||
/// Both paths share the same priming per the native flow capture:
|
||||
/// Stat.GetV ×2 → Stat.GETHI(HistorianVersion) ×2 → UpdC3 → 6 GetSystemParameter →
|
||||
/// GetSystemParameter("AllowRenameTags") → Trx.GetV → Stat.GetV → Retr.GetV.
|
||||
/// </summary>
|
||||
private static void RunWritePriming(
|
||||
IHistoryServiceContract2 historyChannel,
|
||||
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
||||
Binding auxBinding,
|
||||
EndpointAddress statusEndpoint,
|
||||
EndpointAddress transactionEndpoint,
|
||||
EndpointAddress retrievalEndpoint)
|
||||
{
|
||||
string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
|
||||
ChannelFactory<IStatusServiceContract2> statusFactory = new(auxBinding, statusEndpoint);
|
||||
IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
|
||||
ChannelFactory<ITransactionServiceContract> transactionFactory = new(auxBinding, transactionEndpoint);
|
||||
ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
|
||||
ChannelFactory<IRetrievalServiceContract4> retrievalFactory = new(auxBinding, retrievalEndpoint);
|
||||
IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
|
||||
|
||||
try
|
||||
{
|
||||
TryRun(() => statusChannel.GetInterfaceVersion(out _));
|
||||
TryRun(() => statusChannel.GetInterfaceVersion(out _));
|
||||
byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion");
|
||||
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
|
||||
TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
|
||||
|
||||
byte[] clientStatus = BuildUpdC3ClientStatusBlob();
|
||||
historyChannel.UpdateClientStatus3(handle, (uint)clientStatus.Length, ref clientStatus, out _, out _, out _, out _);
|
||||
|
||||
foreach (string parameterName in NativeStatusParametersBeforeAnalogEnsT2)
|
||||
{
|
||||
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
|
||||
}
|
||||
TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, "AllowRenameTags", out _, out _, out _));
|
||||
TryRun(() => transactionChannel.GetInterfaceVersion(out _));
|
||||
TryRun(() => statusChannel.GetInterfaceVersion(out _));
|
||||
TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseSafely(retrievalChannel, retrievalFactory);
|
||||
CloseSafely(transactionChannel, transactionFactory);
|
||||
CloseSafely(statusChannel, statusFactory);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SendDeleteTags(
|
||||
IHistoryServiceContract2 historyChannel,
|
||||
HistorianWcfAuthChainHelper.OpenConnectionContext context,
|
||||
string tagName)
|
||||
{
|
||||
// DelT uses the uint clientHandle, NOT the GUID handle (decoded from wire capture).
|
||||
byte[] tagNamesBytes = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
|
||||
uint statusSize = 0;
|
||||
byte[] status = [];
|
||||
|
||||
return historyChannel.DeleteTags(
|
||||
handle: context.ClientHandle,
|
||||
tagNamesSize: checked((uint)tagNamesBytes.Length),
|
||||
tagNames: tagNamesBytes,
|
||||
statusSize: ref statusSize,
|
||||
status: ref status,
|
||||
errorSize: out _,
|
||||
errorBuffer: out _);
|
||||
}
|
||||
|
||||
private static readonly string[] NativeStatusParametersBeforeAnalogEnsT2 =
|
||||
[
|
||||
"AllowOriginals",
|
||||
"HistorianPartner",
|
||||
"HistorianVersion",
|
||||
"MaxCyclicStorageTimeout",
|
||||
"RealTimeWindow",
|
||||
"FutureTimeThreshold",
|
||||
];
|
||||
|
||||
private static void TryRun(Action a) { try { a(); } catch { } }
|
||||
|
||||
/// <summary>81-byte UpdC3 status blob captured from native (same as event flow).</summary>
|
||||
private static byte[] BuildUpdC3ClientStatusBlob()
|
||||
{
|
||||
byte[] blob = new byte[81];
|
||||
blob[0] = 0x02;
|
||||
blob[1] = 0x01;
|
||||
blob[77] = 0x1E;
|
||||
return blob;
|
||||
}
|
||||
|
||||
/// <summary>GETHI request bytes for a parameter-name query (decoded from native).</summary>
|
||||
private static byte[] BuildGetHistorianInfoRequest(string parameterName)
|
||||
{
|
||||
byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
|
||||
int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0;
|
||||
byte[] buffer = new byte[8 + payloadLength];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length);
|
||||
Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static void CloseSafely(object channel, ICommunicationObject factory)
|
||||
{
|
||||
try { if (channel is ICommunicationObject co) { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } } catch { }
|
||||
try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -366,25 +366,34 @@ internal static class Program
|
||||
ErrorDescription = GetPropertyText(addValueError, "ErrorDescription"),
|
||||
});
|
||||
|
||||
// Optionally delete the tag for clean rollback.
|
||||
if (HasFlag(args, "--write-delete-after"))
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
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"),
|
||||
});
|
||||
}
|
||||
Kind = "DeleteTags",
|
||||
Success = deleteSuccess,
|
||||
ErrorDescription = GetPropertyText(deleteError, "ErrorDescription"),
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (openSuccess && status.ConnectedToServer && IsTagScenario(scenario))
|
||||
|
||||
Reference in New Issue
Block a user