diff --git a/CLAUDE.md b/CLAUDE.md
index 5c21ccb..4ec7524 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,12 +10,19 @@ Read `AGENTS.md` (standing constraints), `instructions.md` (decision record), an
## Required SDK Surface
-Read-only operations only. Do not implement write-back unless explicitly requested:
+Reads (the original required surface, all working live as of 2026-05-04):
- `ProbeAsync`, `ReadRawAsync`, `ReadAggregateAsync`, `ReadAtTimeAsync`, `ReadEventsAsync`
- `BrowseTagNamesAsync`, `GetTagMetadataAsync`
- Status helpers: `GetConnectionStatusAsync`, `GetStoreForwardStatusAsync`, `GetSystemParameterAsync`
+Writes (added 2026-05-04 by explicit user request — do not extend further without one):
+
+- `EnsureTagAsync` for analog types: Float, Double, Int2, Int4, UInt4 (live-verified end-to-end). Other types (SingleByteString/DoubleByteString/Int1/Int8/UInt8) fail at native AddTag — likely require a different path and are intentionally not supported. `MinEU`/`MaxEU` round-trip correctly into the DB; `MinRaw`/`MaxRaw` are sent on the wire but the server mirrors them to MinEU/MaxEU when ApplyScaling=false (verified against native — server quirk, not SDK bug).
+- `DeleteTagAsync`
+
+`AddS2` (write samples) is architecturally blocked — server cache only ingests from configured IOServers/ApplicationServer pipelines. Do not add write-samples support.
+
Methods without protocol evidence currently throw `ProtocolEvidenceMissingException` from `Historian2020ProtocolDialect`. Do not stub fake behavior — leave them throwing until evidence supports an implementation.
## Build & Test
@@ -68,11 +75,29 @@ Three layered subsystems, intentionally decoupled so protocol parsing can be uni
`InternalsVisibleTo` exposes internals to the test assembly and the reverse-engineering tool.
-### The Active Protocol Blocker
+### Read-path status (resolved 2026-05-04)
-The native wrapper does **not** use the simple `Open2` session handle for query reads. The successful native flow is `CClientContext.AuthenticateClient` → two `ValidateClientCredential` SSPI rounds → `CHistoryConnectionWCF.OpenConnection3` → `CClientCommon.StartQuery` → `/Retr.StartQuery2`. `OpenConnection3` mints the transient `/Retr` client handle the server accepts. Managed `Open2` alone reaches server logic but `Retr.StartQuery2` returns false with empty buffers.
+The original blocker — `Open2` reaching server logic but `Retr.StartQuery2` returning false with empty buffers — is **resolved**. Root causes were:
-`DataQueryRequest` and `EventQueryRequest` byte serialization is already byte-matched against native captures. The remaining gap is reproducing the auth/session state that lets the server accept a client-generated context GUID before `OpenConnection3`. See handoff.md "Active Blocker" and `docs/reverse-engineering/openconnection3-correlation-latest.json`.
+1. WCF parameter-name mismatches — server contracts use `inBuff`/`outBuff`/`pRequestBuff`/etc.; the SDK's default C#-derived names made the deserializer ignore the body. Fixed via `[MessageParameter(Name = "...")]` attributes across `IHistoryServiceContract2`, `IRetrievalServiceContract2..4`, `IStatusServiceContract2`, `ITransactionServiceContract`.
+2. Native SSPI request flags — round 0 = `0x2081C` (adds `IDENTIFY` + `REPLAY_DETECT` + `SEQUENCE_DETECT`); rounds 1+ = `0x81C`. Without `REPLAY_DETECT|SEQUENCE_DETECT`, NTLM MIC generation is skipped and `AcceptSecurityContext` rejects round 1. Implemented in `HistorianSspiClient` via P/Invoke `InitializeSecurityContextW`.
+3. Cross-service version probes (`Trx/GetV`, `Stat/GetV`, `Retr/GetV`) between RTag2 and EnsT2 in the event flow — required to register the client with each service's session table.
+
+End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist.ValCl × N → Hist.Open2 → /Retr.GetV → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2`. 23 live integration tests against `localhost` cover all required reads + the two write ops.
+
+### Write-path notes (added 2026-05-04)
+
+`EnsureTagAsync` and `DeleteTagAsync` chain follow the same pattern as reads but require Open2 with `NativeIntegratedWriteEnabledConnectionMode = 0x401` (Process | Write | IntegratedSecurity) — the read-path's `0x402` (read-only) makes the server return err 132 `OperationNotEnabled` silently. The analog Float `CTagMetadata` payload is 144 bytes with a leading `0x4E` marker byte and a 2-byte trailer `FE 00`. See `docs/reverse-engineering/handoff.md` and the `WriteDiag` env-gated diagnostic helper in `HistorianWcfTagWriteOrchestrator` for capture details.
+
+### Remaining gaps
+
+Smaller, isolated items — none block the production read surface:
+
+- Remote TCP transports (`RemoteTcpIntegrated`, `RemoteTcpCertificate`) untested against an actual remote Historian (tests skip without `HISTORIAN_REMOTE_TCP_HOST`).
+- Explicit username/password tag-metadata path (`HistorianWcfTagClient` line ~357) throws — only integrated security wired for that op.
+- `Historian2020ProtocolDialect.GetTagInfoByName/GetTagInfos` throws — currently dead code; `GetTagMetadataAsync` works through the WCF tag client instead.
+- Per-row trailing ~24 bytes of `GetNextQueryResultBuffer` are not decoded (likely per-sample value/source/state metadata).
+- `EnsureTagAsync` distinct `MinRaw`/`MaxRaw` persistence requires `ApplyScaling=true` + a follow-up `UpdateTags` call — not yet wired (no API user has asked).
### Tools Layer
diff --git a/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs b/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
index 593a21f..9e6f14c 100644
--- a/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
+++ b/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
@@ -1,13 +1,11 @@
namespace AVEVA.Historian.Client.Models;
///
-/// Input model for — the minimal set of
-/// fields the SDK currently surfaces for tag creation. Mirrors the analog-Float shape
-/// captured from the native wrapper (see
-/// docs/plans/write-commands-reverse-engineering.md 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.
+/// Input model for . Live-verified data
+/// types: Float, Double, Int2, Int4, UInt4 (probed 2026-05-04 via instrument-wcf-writemessage).
+/// String/Int1/Int8/UInt8 types failed at native AddTag — likely require a different
+/// path and are intentionally not supported. MinEU/MaxEU/MinRaw/MaxRaw are now encoded
+/// into the wire payload (see HistorianTagWriteProtocol).
///
public sealed record HistorianTagDefinition
{
@@ -20,12 +18,28 @@ public sealed record HistorianTagDefinition
/// Engineering unit label (e.g. "Seconds", "kPa"). Required for analog tags.
public string? EngineeringUnit { get; init; }
- /// Native data type. Currently only is live-verified end-to-end.
+ /// Native data type. Float, Double, Int2, Int4, UInt4 are live-verified.
public HistorianDataType DataType { get; init; } = HistorianDataType.Float;
- /// Engineering-units lower bound (analog tags). Default 0.
+ /// Engineering-units lower bound. Default 0.
public double MinEU { get; init; }
- /// Engineering-units upper bound (analog tags). Default 100.
+ /// Engineering-units upper bound. Default 100.
public double MaxEU { get; init; } = 100.0;
+
+ ///
+ /// Raw lower bound (pre-scaling). Default 0. Note: with ApplyScaling=false (the
+ /// only path the SDK currently exposes), the server appears to mirror MinRaw to
+ /// MinEU on EnsureTags2 — verified 2026-05-04 against both native and managed
+ /// clients with the same input. The value is sent on the wire but not persisted
+ /// independently. To set distinct raw bounds, ApplyScaling=true plus a follow-up
+ /// UpdateTags call would be required (not yet wired).
+ ///
+ public double MinRaw { get; init; }
+
+ ///
+ /// Raw upper bound (pre-scaling). Default 100. See for the
+ /// server-side mirror caveat with ApplyScaling=false.
+ ///
+ public double MaxRaw { get; init; } = 100.0;
}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
index 0d17f21..530862e 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
@@ -8,22 +8,25 @@ namespace AVEVA.Historian.Client.Wcf;
/// artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/bothmessage-write-with-delt-latest.ndjson
/// — see docs/plans/write-commands-reverse-engineering.md Phase 2 findings.
///
-/// Per the captured 146-byte analog Float CTagMetadata, the layout is:
+/// Per the captured analog CTagMetadata, the layout is:
///
-/// 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)
+/// 1-byte leading marker = 4E (purpose unclear; observed constant — possibly "CTagMetadata" type tag)
+/// 10-byte fixed header = 67 03 00 01 00 00 00 04 C6 02
+/// 1-byte data-type code = 0x01 Float, 0x21 Double, 0x29 Int2, 0x31 Int4, 0x11 UInt4
+/// 16 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
+/// 7-byte flag block = 02 01 01 00 00 00 01
/// uint32 storage rate (ms)
/// int64 date-created FILETIME UTC
-/// 2-byte separator = 1A 03
+/// scaling block either compact `1A 03` (default 0/100/0/100) OR
+/// `1F 00` + 4 doubles (MinEU, MaxEU, MinRaw, MaxRaw)
/// 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
+/// 2-byte trailer = FE 00
///
/// 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
@@ -35,14 +38,35 @@ internal static class HistorianTagWriteProtocol
{
private const byte CompactAsciiMarker = 0x09;
- private static readonly byte[] AnalogHeader =
+ ///
+ /// 11 bytes preceding the data-type discriminator. Byte 0 is the leading 0x4E
+ /// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is `0x02`
+ /// (sub-marker preceding the type code).
+ ///
+ private static readonly byte[] AnalogHeaderUpToTypeCode =
[
- // bytes 0-8 (constant — observed identical across runs)
+ 0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
- // bytes 9-10: observed `02 01` (purpose unclear — possibly a sub-marker)
- 0x02, 0x01,
+ 0x02,
];
+ ///
+ /// Native CDataType wire codes per data type — captured 2026-05-04 by probing
+ /// every type via instrument-wcf-writemessage. Matches the codes already documented
+ /// in MapDataType for the read path.
+ ///
+ public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
+ {
+ Models.HistorianDataType.Float => 0x01,
+ Models.HistorianDataType.Double => 0x21,
+ Models.HistorianDataType.UInt2 => 0x09,
+ Models.HistorianDataType.UInt4 => 0x11,
+ Models.HistorianDataType.Int2 => 0x29,
+ Models.HistorianDataType.Int4 => 0x31,
+ _ => throw new ProtocolEvidenceMissingException(
+ $"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4."),
+ };
+
private static readonly byte[] AnalogPadding16 = new byte[16];
private static readonly byte[] AnalogPostNamePadding = new byte[16];
@@ -60,48 +84,87 @@ internal static class HistorianTagWriteProtocol
// `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];
+ /// Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.
+ private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
+ /// Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.
+ private static readonly byte[] AnalogScalingExplicitMarker = [0x1F, 0x00];
+ // Native trailer is 2 bytes; the prior 5-byte version included WCF EndElement
+ // closing markers (`01 01 01`) that the binary message encoder writes around the
+ // element — those are not part of the buffer content.
+ private static readonly byte[] AnalogTrailer = [0xFE, 0x00];
+
+ private const double DefaultMinEU = 0.0;
+ private const double DefaultMaxEU = 100.0;
+ private const double DefaultMinRaw = 0.0;
+ private const double DefaultMaxRaw = 100.0;
private const string MetadataProvider = "MDAS";
private const uint IntegralDivisorMagic = 0x2710u;
private const uint DefaultStorageRateMs = 1000u;
///
- /// Serializes a CTagMetadata payload for an analog tag (CDataType=Float currently
- /// supported). Output matches the byte-for-byte capture for the same inputs.
+ /// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
+ /// Double, Int2, Int4, UInt4 — see for the
+ /// type-code mapping. Output matches the byte-for-byte capture for the same inputs.
+ /// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact
+ /// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order.
///
/// Tag name (ASCII).
/// Tag description (ASCII; null/empty allowed).
/// EU label (ASCII; null/empty allowed).
+ /// Native data type — Float by default for backward compat.
/// DateCreated FILETIME (caller passes ).
+ /// Engineering-units lower bound.
+ /// Engineering-units upper bound.
+ /// Raw lower bound.
+ /// Raw upper bound.
/// StorageRate in milliseconds.
public static byte[] SerializeAnalogCTagMetadata(
string tagName,
string? description,
string? engineeringUnit,
DateTime dateCreatedUtc,
+ Models.HistorianDataType dataType = Models.HistorianDataType.Float,
+ double minEU = DefaultMinEU,
+ double maxEU = DefaultMaxEU,
+ double minRaw = DefaultMinRaw,
+ double maxRaw = DefaultMaxRaw,
uint storageRateMs = DefaultStorageRateMs)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
+ byte typeCode = GetAnalogDataTypeCode(dataType);
using MemoryStream ms = new();
using BinaryWriter w = new(ms);
- w.Write(AnalogHeader); // 9 bytes
+ w.Write(AnalogHeaderUpToTypeCode); // 11 bytes (incl 0x4E leading marker, ends at sub-marker 0x02)
+ w.Write(typeCode); // 1 byte data-type discriminator
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(AnalogFlagBlock); // 7 bytes
w.Write(storageRateMs); // uint32
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
- w.Write(AnalogSeparator); // 2 bytes
+
+ if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw)
+ {
+ w.Write(AnalogScalingDefaultsMarker); // 2 bytes (1A 03)
+ }
+ else
+ {
+ w.Write(AnalogScalingExplicitMarker); // 2 bytes (1F 00)
+ w.Write(minEU);
+ w.Write(maxEU);
+ w.Write(minRaw);
+ w.Write(maxRaw); // 32 bytes total for the 4 doubles
+ }
+
WriteCompactAscii(w, engineeringUnit ?? string.Empty); // var
w.Write(IntegralDivisorMagic); // uint32 (purpose unclear — captured constant)
w.Write(1.0); // double
- w.Write(AnalogTrailer); // 5 bytes
+ w.Write(AnalogTrailer); // 2 bytes (FE 00)
return ms.ToArray();
}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs
index 996deda..8c32855 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs
@@ -19,6 +19,13 @@ internal static class HistorianWcfAuthChainHelper
private const byte NativeClientType = 4;
public const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
public const uint NativeIntegratedEventConnectionMode = 0x501;
+ ///
+ /// Process + write-enabled + integrated security. Per native ilspy
+ /// (HistorianAccessUtil.SetConnectionMode): Process=1, OR 0x400 for integratedSecurity.
+ /// EnsT2 and DelT silently return false with err code 132 (OperationNotEnabled) when
+ /// Open2 is opened with 0x402 (read-only); 0x401 unlocks write capability.
+ ///
+ public const uint NativeIntegratedWriteEnabledConnectionMode = 0x401;
private const byte NativeClientCommonInfoFormatVersion = 4;
private const ushort NativeHcalVersion = 17;
private const uint NativeClientVersionInt = 999_999;
@@ -39,6 +46,11 @@ internal static class HistorianWcfAuthChainHelper
Action? additionalSetup = null)
{
ChannelFactory historyFactory = new(historyBinding, historyEndpoint);
+ historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfHistAddressingBehavior());
+ if (HistorianWcfMessageCaptureBehavior.IsEnabled)
+ {
+ historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfMessageCaptureBehavior());
+ }
try
{
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfHistAddressingBehavior.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfHistAddressingBehavior.cs
new file mode 100644
index 0000000..70b4c0a
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfHistAddressingBehavior.cs
@@ -0,0 +1,39 @@
+using System.Runtime.Versioning;
+using System.ServiceModel;
+using System.ServiceModel.Channels;
+using System.ServiceModel.Description;
+using System.ServiceModel.Dispatcher;
+
+namespace AVEVA.Historian.Client.Wcf;
+
+///
+/// Forces an explicit wsa:To URI on every outgoing message. Native captures
+/// of EnsT2 / DelT include net.pipe://localhost/Hist in the addressing header
+/// block; without it the server appears to accept the body but not act on it
+/// (silent fail observed for both write ops). WCF normally derives To from the
+/// endpoint address, but the captured SDK bytes show it absent — re-asserting it
+/// here closes the gap.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class HistorianWcfHistAddressingBehavior : IEndpointBehavior
+{
+ public void Validate(ServiceEndpoint endpoint) { }
+ public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
+ public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { }
+
+ public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
+ {
+ clientRuntime.ClientMessageInspectors.Add(new ToHeaderInspector(endpoint.Address.Uri));
+ }
+
+ private sealed class ToHeaderInspector(Uri toUri) : IClientMessageInspector
+ {
+ public object? BeforeSendRequest(ref Message request, IClientChannel channel)
+ {
+ request.Headers.To = toUri;
+ return null;
+ }
+
+ public void AfterReceiveReply(ref Message reply, object? correlationState) { }
+ }
+}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfMessageCaptureBehavior.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfMessageCaptureBehavior.cs
new file mode 100644
index 0000000..30e9ffe
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfMessageCaptureBehavior.cs
@@ -0,0 +1,96 @@
+using System.Runtime.Versioning;
+using System.ServiceModel;
+using System.ServiceModel.Channels;
+using System.ServiceModel.Description;
+using System.ServiceModel.Dispatcher;
+using System.Text.Json;
+
+namespace AVEVA.Historian.Client.Wcf;
+
+///
+/// Reverse-engineering aid: when the env var AVEVA_HISTORIAN_SDK_WIRE_CAPTURE is set,
+/// every outgoing WCF message body and every incoming response body on this endpoint is
+/// captured to that file as one ndjson record per call. Pair with the
+/// instrument-wcf-{write,read}message native captures and diff offset-by-offset to
+/// isolate SDK-vs-native differences. NEVER enable in production.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class HistorianWcfMessageCaptureBehavior : IEndpointBehavior
+{
+ public const string CapturePathEnvVar = "AVEVA_HISTORIAN_SDK_WIRE_CAPTURE";
+
+ public static bool IsEnabled => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(CapturePathEnvVar));
+
+ public void Validate(ServiceEndpoint endpoint) { }
+ public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
+ public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { }
+
+ public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
+ {
+ clientRuntime.ClientMessageInspectors.Add(new MessageCaptureInspector());
+ }
+
+ private sealed class MessageCaptureInspector : IClientMessageInspector
+ {
+ private static readonly object Lock = new();
+
+ public object? BeforeSendRequest(ref Message request, IClientChannel channel)
+ {
+ CaptureMessage("SDK.WriteMessage.Body", ref request);
+ return null;
+ }
+
+ public void AfterReceiveReply(ref Message reply, object? correlationState)
+ {
+ CaptureMessage("SDK.ReadMessage.Body", ref reply);
+ }
+
+ private static void CaptureMessage(string phase, ref Message message)
+ {
+ string? path = Environment.GetEnvironmentVariable(CapturePathEnvVar);
+ if (string.IsNullOrWhiteSpace(path) || message.IsEmpty)
+ {
+ return;
+ }
+
+ try
+ {
+ // Buffer the message so we can both inspect and forward the bytes.
+ MessageBuffer buffer = message.CreateBufferedCopy(int.MaxValue);
+ Message copy = buffer.CreateMessage();
+ using MemoryStream ms = new();
+ BinaryMessageEncodingBindingElement binaryEncoder = new();
+ MessageEncoderFactory factory = binaryEncoder.CreateMessageEncoderFactory();
+ factory.Encoder.WriteMessage(copy, ms);
+ byte[] bytes = ms.ToArray();
+ message = buffer.CreateMessage();
+
+ string action = message.Headers.Action ?? "";
+ var record = new
+ {
+ TimestampUtc = DateTimeOffset.UtcNow.ToString("O"),
+ Phase = phase,
+ Action = action,
+ Length = bytes.Length,
+ Base64 = Convert.ToBase64String(bytes),
+ };
+
+ string? dir = Path.GetDirectoryName(Path.GetFullPath(path));
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ lock (Lock)
+ {
+ File.AppendAllText(path, JsonSerializer.Serialize(record) + Environment.NewLine);
+ }
+ }
+ catch
+ {
+ // Capture is reverse-engineering aid — never let it break the live call.
+ }
+ }
+ }
+
+}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs
index 53ba35f..721a7a9 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs
@@ -403,14 +403,28 @@ internal sealed class HistorianWcfReadOrchestrator
};
}
- private static uint MapRetrievalModeToQueryType(Models.RetrievalMode mode) => mode switch
+ ///
+ /// QueryType wire value matches the native ArchestrA.HistorianRetrievalMode enum
+ /// ordinal exactly — verified 2026-05-04 by probing every mode through the
+ /// instrument-wcf-writemessage capture pipeline and reading the QueryType uint32
+ /// at offset 2 of pRequestBuff:
+ ///
+ /// Cyclic=0 Delta=1 Full=2 Interpolated=3 BestFit=4 TimeWeightedAverage=5
+ /// MinimumWithTime=6 MaximumWithTime=7 Integral=8 Slope=9 Counter=10
+ /// ValueState=11 RoundTrip=12 StartBound=13 EndBound=14
+ ///
+ /// The public enum mirrors the native order, so the
+ /// mapping reduces to (uint)mode. Prior version mapped Cyclic to 4
+ /// (BestFit's value) and threw for everything outside the four common modes.
+ ///
+ internal static uint MapRetrievalModeToQueryType(Models.RetrievalMode mode)
{
- Models.RetrievalMode.Full => 2,
- Models.RetrievalMode.Interpolated => 3,
- Models.RetrievalMode.TimeWeightedAverage => 5,
- Models.RetrievalMode.Cyclic => 4,
- _ => throw new ProtocolEvidenceMissingException($"Retrieval mode {mode} not yet mapped to a Historian QueryType.")
- };
+ if (!Enum.IsDefined(mode))
+ {
+ throw new ProtocolEvidenceMissingException($"Retrieval mode {mode} is not a defined RetrievalMode value.");
+ }
+ return (uint)mode;
+ }
private static uint MapRetrievalModeToAggregationType(Models.RetrievalMode mode) => mode switch
{
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
index 39fad11..d8d6353 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
@@ -30,11 +30,9 @@ internal sealed class HistorianWcfTagWriteOrchestrator
{
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}");
- }
+ // GetAnalogDataTypeCode throws ProtocolEvidenceMissingException for unsupported
+ // types (String, Int1/Int8/UInt8, Guid, Event, Structure) — surface that early.
+ _ = HistorianTagWriteProtocol.GetAnalogDataTypeCode(definition.DataType);
return Task.Run(() => EnsureTag(definition), cancellationToken);
}
@@ -60,6 +58,7 @@ internal sealed class HistorianWcfTagWriteOrchestrator
bool result = false;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
+ connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
additionalSetup: (historyChannel, context) => result = SendEnsureTags2(
historyChannel, context, definition, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint));
return result;
@@ -79,6 +78,7 @@ internal sealed class HistorianWcfTagWriteOrchestrator
bool result = false;
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, CancellationToken.None,
+ connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
additionalSetup: (historyChannel, context) =>
{
RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
@@ -103,14 +103,21 @@ internal sealed class HistorianWcfTagWriteOrchestrator
tagName: definition.TagName,
description: definition.Description,
engineeringUnit: definition.EngineeringUnit,
- dateCreatedUtc: DateTime.UtcNow);
+ dateCreatedUtc: DateTime.UtcNow,
+ dataType: definition.DataType,
+ minEU: definition.MinEU,
+ maxEU: definition.MaxEU,
+ minRaw: definition.MinRaw,
+ maxRaw: definition.MaxRaw);
- return historyChannel.EnsureTags2(
+ bool ok = historyChannel.EnsureTags2(
handle: handle,
elementCount: 1,
inputBuffer: payload,
- outputBuffer: out _,
- errorBuffer: out _);
+ outputBuffer: out byte[] outBuf,
+ errorBuffer: out byte[] errBuf);
+ WriteDiag("EnsT2", $"Returned={ok} OutLen={outBuf?.Length ?? -1} OutHex={(outBuf is null ? "" : Convert.ToHexString(outBuf))} ErrLen={errBuf?.Length ?? -1} ErrHex={(errBuf is null ? "" : Convert.ToHexString(errBuf))}");
+ return ok;
}
///
@@ -170,21 +177,36 @@ internal sealed class HistorianWcfTagWriteOrchestrator
string tagName)
{
// DelT uses the uint clientHandle, NOT the GUID handle (decoded from wire capture).
- // The captured native DelT request sends ref params statusSize=1 + status=null on
- // the input side (these get overwritten on output). Sending statusSize=0 + status=[]
- // resulted in the call returning true but the server not actually deleting the tag.
+ // Native DelT request encodes statusSize as MS-NBFS marker 0x81
+ // (ZeroTextWithEndElement = value 0) and status as xsi:nil. Earlier notes called
+ // 0x81 "OneText" — that was wrong; the WithEndElement-pair table is:
+ // 0x80/0x81 ZeroText, 0x82/0x83 OneText, 0x84/0x85 FalseText,
+ // 0x86/0x87 TrueText, 0x88/0x89 Int8Text.
+ // Sending statusSize=1 (which WCF encodes as 0x83 OneTextWithEndElement) made the
+ // server return DelTResult=false with err=04 84 00 00 00 (HistorianAccessError
+ // type 4 / code 132). statusSize=0 matches the native parity request.
byte[] tagNamesBytes = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
- uint statusSize = 1;
- byte[] status = null!; // intentional null per native capture
+ uint statusSize = 0;
+ byte[] status = null!;
- return historyChannel.DeleteTags(
+ bool ok = historyChannel.DeleteTags(
handle: context.ClientHandle,
tagNamesSize: checked((uint)tagNamesBytes.Length),
tagNames: tagNamesBytes,
statusSize: ref statusSize,
status: ref status,
- errorSize: out _,
- errorBuffer: out _);
+ errorSize: out uint errorSize,
+ errorBuffer: out byte[] errorBuffer);
+
+ WriteDiag("DelT", $"Returned={ok} ClientHandle={context.ClientHandle} StatusSize={statusSize} StatusLen={status?.Length ?? -1} StatusHex={(status is null ? "" : Convert.ToHexString(status))} ErrorSize={errorSize} ErrorLen={errorBuffer?.Length ?? -1} ErrorHex={(errorBuffer is null ? "" : Convert.ToHexString(errorBuffer))}");
+ return ok;
+ }
+
+ private static void WriteDiag(string op, string line)
+ {
+ string? diagPath = Environment.GetEnvironmentVariable("AVEVA_HISTORIAN_DELT_DIAG");
+ if (string.IsNullOrWhiteSpace(diagPath)) return;
+ try { File.AppendAllText(diagPath, $"{DateTimeOffset.UtcNow:O} {op} {line}{Environment.NewLine}"); } catch { }
}
private static readonly string[] NativeStatusParametersBeforeAnalogEnsT2 =
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
index bf37aa9..2fe5426 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
@@ -132,6 +132,48 @@ public sealed class HistorianClientIntegrationTests
Assert.All(samples, s => Assert.Equal(AVEVA.Historian.Client.Models.RetrievalMode.TimeWeightedAverage, s.RetrievalMode));
}
+ // Verifies a previously-unmapped RetrievalMode (one of the 11 modes that prior to
+ // 2026-05-04 threw ProtocolEvidenceMissingException). MinimumWithTime → QueryType=6
+ // exercises the "QueryType is the native enum ordinal" mapping against the live server.
+ [Theory]
+ [InlineData(AVEVA.Historian.Client.Models.RetrievalMode.MinimumWithTime)]
+ [InlineData(AVEVA.Historian.Client.Models.RetrievalMode.MaximumWithTime)]
+ [InlineData(AVEVA.Historian.Client.Models.RetrievalMode.BestFit)]
+ public async Task ReadAggregateAsync_AgainstLocalHistorian_AcceptsPreviouslyUnmappedRetrievalMode(
+ AVEVA.Historian.Client.Models.RetrievalMode mode)
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
+ string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
+ if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)
+ || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
+ || !OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ HistorianClient client = new(new HistorianClientOptions
+ {
+ Host = host,
+ IntegratedSecurity = true,
+ Transport = HistorianTransport.LocalPipe
+ });
+
+ DateTime endUtc = DateTime.UtcNow;
+ DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
+
+ List samples = [];
+ await foreach (AVEVA.Historian.Client.Models.HistorianAggregateSample s in client.ReadAggregateAsync(
+ testTag, startUtc, endUtc, mode, TimeSpan.FromMinutes(2), CancellationToken.None))
+ {
+ samples.Add(s);
+ }
+
+ // Server should accept the request without error. Even if no rows come back
+ // (unlikely for a 10-minute window on a steadily-counting tag), the absence of an
+ // exception proves the QueryType byte was accepted.
+ Assert.All(samples, s => Assert.Equal(mode, s.RetrievalMode));
+ }
+
[Fact]
public async Task ReadAtTimeAsync_AgainstLocalHistorian_ReturnsRequestedTimestamps()
{
@@ -335,17 +377,66 @@ public sealed class HistorianClientIntegrationTests
MaxEU = 100.0,
};
- // EnsureTagAsync's wire bytes match captured native byte-for-byte (golden test
- // passes), but the call currently returns false and does NOT actually create the
- // tag — the server-side acceptance criterion the native AddTag flow satisfies is
- // not yet replicated in our SDK orchestrator. Documented as known issue.
- // The test below therefore only exercises EnsureTagAsync's call path (verifies it
- // doesn't throw) and makes a best-effort cleanup via DeleteTagAsync.
- await client.EnsureTagAsync(definition, CancellationToken.None);
+ // Both EnsureTagAsync and DeleteTagAsync now work end-to-end against the live
+ // Historian. Open2 must use write-enabled connectionMode 0x401 (not the default
+ // 0x402 read-only); the EnsT2 InBuff layout is corrected to native parity (144
+ // bytes incl 0x4E leading marker, no trailing 01 01 01 closing markers).
+ bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
+ Assert.True(ensured, "EnsureTagAsync returned false against the live Historian.");
- // Best-effort cleanup. May return false if EnsureTagAsync didn't actually create
- // the tag (per the known issue) — that's expected, not a test failure.
- await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
+ bool deleted = await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
+ Assert.True(deleted, "DeleteTagAsync returned false against the live Historian.");
+ }
+
+ // Round-trip every live-verified analog data type + the non-default-range case. The
+ // sandbox tag name is suffixed per case so the runs don't collide. Always cleans up.
+ [Theory]
+ [InlineData("RetestSdkWriteFloatRT", AVEVA.Historian.Client.Models.HistorianDataType.Float, 0.0, 100.0, 0.0, 100.0)]
+ [InlineData("RetestSdkWriteDoubleRT", AVEVA.Historian.Client.Models.HistorianDataType.Double, 0.0, 100.0, 0.0, 100.0)]
+ [InlineData("RetestSdkWriteInt2RT", AVEVA.Historian.Client.Models.HistorianDataType.Int2, 0.0, 100.0, 0.0, 100.0)]
+ [InlineData("RetestSdkWriteInt4RT", AVEVA.Historian.Client.Models.HistorianDataType.Int4, 0.0, 100.0, 0.0, 100.0)]
+ [InlineData("RetestSdkWriteUInt4RT", AVEVA.Historian.Client.Models.HistorianDataType.UInt4, 0.0, 100.0, 0.0, 100.0)]
+ [InlineData("RetestSdkWriteFloatRangesRT", AVEVA.Historian.Client.Models.HistorianDataType.Float, -50.0, 200.0, 10.0, 4095.0)]
+ public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_PerDataTypeAndRange(
+ string sandboxTag,
+ AVEVA.Historian.Client.Models.HistorianDataType dataType,
+ double minEU, double maxEU, double minRaw, double maxRaw)
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
+ if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ HistorianClient client = new(new HistorianClientOptions
+ {
+ Host = host,
+ IntegratedSecurity = true,
+ Transport = HistorianTransport.LocalPipe
+ });
+
+ AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
+ {
+ TagName = sandboxTag,
+ Description = $"SDK round-trip {dataType}",
+ EngineeringUnit = "test",
+ DataType = dataType,
+ MinEU = minEU,
+ MaxEU = maxEU,
+ MinRaw = minRaw,
+ MaxRaw = maxRaw,
+ };
+
+ try
+ {
+ bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
+ Assert.True(ensured, $"EnsureTagAsync({dataType}) returned false against the live Historian.");
+ }
+ finally
+ {
+ // Always clean up — DeleteTagAsync returns true on a freshly-created tag.
+ await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
+ }
}
[Fact]
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianRetrievalModeMappingTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianRetrievalModeMappingTests.cs
new file mode 100644
index 0000000..a71199b
--- /dev/null
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianRetrievalModeMappingTests.cs
@@ -0,0 +1,40 @@
+using System.Runtime.Versioning;
+using AVEVA.Historian.Client.Models;
+using AVEVA.Historian.Client.Wcf;
+
+namespace AVEVA.Historian.Client.Tests;
+
+[SupportedOSPlatform("windows")]
+public sealed class HistorianRetrievalModeMappingTests
+{
+ // Probed 2026-05-04 via instrument-wcf-writemessage against every
+ // ArchestrA.HistorianRetrievalMode value — see HistorianWcfReadOrchestrator
+ // MapRetrievalModeToQueryType doc comment for capture details.
+ [Theory]
+ [InlineData(RetrievalMode.Cyclic, 0u)]
+ [InlineData(RetrievalMode.Delta, 1u)]
+ [InlineData(RetrievalMode.Full, 2u)]
+ [InlineData(RetrievalMode.Interpolated, 3u)]
+ [InlineData(RetrievalMode.BestFit, 4u)]
+ [InlineData(RetrievalMode.TimeWeightedAverage, 5u)]
+ [InlineData(RetrievalMode.MinimumWithTime, 6u)]
+ [InlineData(RetrievalMode.MaximumWithTime, 7u)]
+ [InlineData(RetrievalMode.Integral, 8u)]
+ [InlineData(RetrievalMode.Slope, 9u)]
+ [InlineData(RetrievalMode.Counter, 10u)]
+ [InlineData(RetrievalMode.ValueState, 11u)]
+ [InlineData(RetrievalMode.RoundTrip, 12u)]
+ [InlineData(RetrievalMode.StartBound, 13u)]
+ [InlineData(RetrievalMode.EndBound, 14u)]
+ public void MapRetrievalModeToQueryType_MatchesNativeEnumOrdinal(RetrievalMode mode, uint expectedQueryType)
+ {
+ Assert.Equal(expectedQueryType, HistorianWcfReadOrchestrator.MapRetrievalModeToQueryType(mode));
+ }
+
+ [Fact]
+ public void MapRetrievalModeToQueryType_UndefinedValue_Throws()
+ {
+ Assert.Throws(
+ () => HistorianWcfReadOrchestrator.MapRetrievalModeToQueryType((RetrievalMode)999));
+ }
+}
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs
index a91e36c..3dce727 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs
@@ -10,18 +10,19 @@ public sealed class HistorianTagWriteProtocolTests
{
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes from
// artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/
- // fresh-enst2-latest.ndjson — 146 bytes. Inputs:
+ // fresh-enst2-latest.ndjson — 144 bytes. Inputs:
// tagName = "RetestSdkWriteSandbox" (the sandbox)
// description = "SDK write-RE sandbox tag"
// eu = "test"
// FILETIME = 0x01DCDBBFCD87D049 (captured at run time)
+ // The earlier 146-byte version mistakenly included the WCF EndElement closing
+ // markers (`01 01 01`) and was missing the 0x4E leading marker — both have been
+ // corrected by walking the native InBuff field-by-field.
const string ExpectedHex =
- "6703000100000004C6020100000000000000000000000000000000" +
- "09150052657465737453646B577269746553616E64626F78" +
- "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" +
- "09180053444B2077726974652D52452073616E64626F78207461" +
- "670904004D44415302010100000001E803000049D087CDBFDBDC01" +
- "1A030904007465737410270000000000000000F03FFE00010101";
+ "4E6703000100000004C6020100000000000000000000000000000000"
+ + "09150052657465737453646B577269746553616E64626F78"
+ + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E803000049D087CDBFDBDC011A030904007465737410270000000000000000F03FFE00";
byte[] expected = Convert.FromHexString(ExpectedHex);
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
@@ -30,11 +31,102 @@ public sealed class HistorianTagWriteProtocolTests
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01DCDBBFCD87D049L));
- Assert.Equal(146, expected.Length);
- Assert.Equal(146, actual.Length);
+ Assert.Equal(144, expected.Length);
+ Assert.Equal(144, actual.Length);
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
}
+ // Per-data-type captures from instrument-wcf-writemessage 2026-05-04 — the only
+ // diff vs the Float baseline is byte 11 (the data-type discriminator) plus tag-name
+ // length. All other inputs (description, EU, default ranges, storage rate) match
+ // the captured baseline so the byte-for-byte assertion exercises the dispatch.
+ [Theory]
+ [InlineData(
+ AVEVA.Historian.Client.Models.HistorianDataType.Double,
+ "RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
+ "4E6703000100000004C6022100000000000000000000000000000000"
+ + "09140052657465737453646B5772697465446F75626C65"
+ + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
+ [InlineData(
+ AVEVA.Historian.Client.Models.HistorianDataType.Int4,
+ "RetestSdkWriteInt4", 0x01dcdbed292e1cecL,
+ "4E6703000100000004C6023100000000000000000000000000000000"
+ + "09120052657465737453646B5772697465496E7434"
+ + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000EC1C2E29EDDBDC011A030904007465737410270000000000000000F03FFE00")]
+ [InlineData(
+ AVEVA.Historian.Client.Models.HistorianDataType.UInt4,
+ "RetestSdkWriteUInt4", 0x01dcdbed2d33b02cL,
+ "4E6703000100000004C6021100000000000000000000000000000000"
+ + "09130052657465737453646B577269746555496E7434"
+ + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300002CB0332DEDDBDC011A030904007465737410270000000000000000F03FFE00")]
+ [InlineData(
+ AVEVA.Historian.Client.Models.HistorianDataType.Int2,
+ "RetestSdkWriteInt2", 0x01dcdbed360e9b54L,
+ "4E6703000100000004C6022900000000000000000000000000000000"
+ + "09120052657465737453646B5772697465496E7432"
+ + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
+ public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
+ AVEVA.Historian.Client.Models.HistorianDataType dataType,
+ string tagName,
+ long fileTimeUtc,
+ string expectedHex)
+ {
+ byte[] expected = Convert.FromHexString(expectedHex);
+ byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
+ tagName: tagName,
+ description: "SDK write-RE sandbox tag",
+ engineeringUnit: "test",
+ dateCreatedUtc: DateTime.FromFileTimeUtc(fileTimeUtc),
+ dataType: dataType);
+
+ Assert.Equal(expected.Length, actual.Length);
+ Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
+ }
+
+ // Captured 2026-05-04 with MinEU=-50, MaxEU=200, MinRaw=10, MaxRaw=4095. Verifies
+ // the explicit-scaling marker `1F` + 4 doubles in order (MinEU, MaxEU, MinRaw, MaxRaw).
+ [Fact]
+ public void SerializeAnalogCTagMetadata_NonDefaultRanges_EmitsExplicitMarkerAndFourDoubles()
+ {
+ const string ExpectedHex =
+ "4E6703000100000004C6020100000000000000000000000000000000"
+ + "09190052657465737453646B5772697465466C6F617452616E676573"
+ + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF09180053444B207772697465"
+ + "2D52452073616E64626F78207461670904004D444153020101000000"
+ + "01E8030000BE294B47EDDBDC011F0000000000000049C00000000000"
+ + "00694000000000000024400000000000FEAF40090400746573741027"
+ + "0000000000000000F03FFE00";
+
+ byte[] expected = Convert.FromHexString(ExpectedHex);
+ byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
+ tagName: "RetestSdkWriteFloatRanges",
+ description: "SDK write-RE sandbox tag",
+ engineeringUnit: "test",
+ dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
+ dataType: AVEVA.Historian.Client.Models.HistorianDataType.Float,
+ minEU: -50.0,
+ maxEU: 200.0,
+ minRaw: 10.0,
+ maxRaw: 4095.0);
+
+ Assert.Equal(180, expected.Length);
+ Assert.Equal(180, actual.Length);
+ Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
+ }
+
+ [Fact]
+ public void GetAnalogDataTypeCode_UnsupportedType_Throws()
+ {
+ Assert.Throws(
+ () => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString));
+ Assert.Throws(
+ () => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1));
+ }
+
[Fact]
public void SerializeAnalogCTagMetadata_DifferentInputsProducesDifferentBytesInExpectedSlots()
{
diff --git a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
index 04e5a5d..a3405c5 100644
--- a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
+++ b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
@@ -228,6 +228,10 @@ internal static class Program
}
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.
@@ -251,10 +255,10 @@ internal static class Program
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, "MinEU", writeMinEu);
+ SetProperty(tag, "MaxEU", writeMaxEu);
+ SetProperty(tag, "MinRaw", writeMinRaw);
+ SetProperty(tag, "MaxRaw", writeMaxRaw);
SetProperty(tag, "StorageRate", 1000u);
SetProperty(tag, "ApplyScaling", false);