Resolve write-path silent fails + expand EnsureTagAsync, RetrievalMode coverage
DelT and EnsT2 had two distinct silent-fail blockers; both now resolved live end-to-end. Read path's RetrievalMode mapping was missing 11 of 15 enum values (plus a latent Cyclic→4 bug). Investigation tooling kept as env-gated helpers. DelT silent fail: Open2 was using NativeIntegratedReadOnlyConnectionMode (0x402); server returned err 132 OperationNotEnabled silently. Added NativeIntegratedWriteEnabledConnectionMode (0x401) per HistorianAccessUtil.SetConnectionMode bit map (Process=1 | IntegratedSecurity=0x400). Write orchestrator now opens with write-enabled mode. EnsT2 silent fail: byte-by-byte comparison via inspector revealed two bugs in SerializeAnalogCTagMetadata. The original "146-byte byte-for-byte match" was misaligned — it omitted the leading 0x4E marker byte and treated WCF's `01 01 01` EndElement closing markers as if they were part of the InBuff payload. Real native InBuff is 144 bytes with 0x4E lead and 2-byte `FE 00` trailer. Golden test bytes corrected. EnsureTagAsync expansion: probed every analog data type via instrument-wcf-writemessage; byte 11 of CTagMetadata is the data-type discriminator (Float=0x01, Double=0x21, UInt2=0x09, UInt4=0x11, Int2=0x29, Int4=0x31). String/Int1/Int8/UInt8 fail at native AddTag — out of scope for this op. Range encoding decoded: defaults emit compact `1A 03`; non-default emit `1F 00` + 4 doubles in order MinEU/MaxEU/MinRaw/MaxRaw. MinRaw/MaxRaw sent on the wire but server mirrors them to MinEU/MaxEU when ApplyScaling=false (verified against native — server quirk, not SDK bug). RetrievalMode mapping: probed all 15 enum values; QueryType is just the native enum ordinal. Replaced the broken switch with `(uint)mode`. Existing SDK mapped Cyclic→4 (BestFit's value); Cyclic is actually 0. CLAUDE.md updated: stale "Active Protocol Blocker" rewritten as resolved-status block; SDK surface now reflects the read-blocker resolution and the new write ops; "Remaining gaps" punch list refreshed. Tools added (both env-gated, no runtime overhead unless flipped on): - HistorianWcfMessageCaptureBehavior — captures all WCF body bytes when AVEVA_HISTORIAN_SDK_WIRE_CAPTURE is set; used for byte-level diff vs native. - HistorianWcfHistAddressingBehavior — explicitly sets wsa:To header on the Hist channel for parity with native bytes (kept though not load-bearing). - WriteDiag in TagWriteOrchestrator — env-gated EnsT2/DelT response logging (AVEVA_HISTORIAN_DELT_DIAG). NativeTraceHarness CLI: added --write-min-eu/--write-max-eu/--write-min-raw/ --write-max-raw for capturing non-default-range EnsT2 payloads. Tests: 130 → 161 passing (+31). Includes 16-mode RetrievalMode mapping table, 4 per-data-type EnsT2 golden tests, NonDefaultRanges golden test, 6 live round-trip integration tests covering Float/Double/Int2/Int4/UInt4/FloatRanges, 3 live tests for previously-unmapped RetrievalMode values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
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.
|
||||
/// Input model for <see cref="HistorianClient.EnsureTagAsync"/>. 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 <c>HistorianTagWriteProtocol</c>).
|
||||
/// </summary>
|
||||
public sealed record HistorianTagDefinition
|
||||
{
|
||||
@@ -20,12 +18,28 @@ public sealed record HistorianTagDefinition
|
||||
/// <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>
|
||||
/// <summary>Native data type. Float, Double, Int2, Int4, UInt4 are live-verified.</summary>
|
||||
public HistorianDataType DataType { get; init; } = HistorianDataType.Float;
|
||||
|
||||
/// <summary>Engineering-units lower bound (analog tags). Default 0.</summary>
|
||||
/// <summary>Engineering-units lower bound. Default 0.</summary>
|
||||
public double MinEU { get; init; }
|
||||
|
||||
/// <summary>Engineering-units upper bound (analog tags). Default 100.</summary>
|
||||
/// <summary>Engineering-units upper bound. Default 100.</summary>
|
||||
public double MaxEU { get; init; } = 100.0;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public double MinRaw { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw upper bound (pre-scaling). Default 100. See <see cref="MinRaw"/> for the
|
||||
/// server-side mirror caveat with ApplyScaling=false.
|
||||
/// </summary>
|
||||
public double MaxRaw { get; init; } = 100.0;
|
||||
}
|
||||
|
||||
@@ -8,22 +8,25 @@ namespace AVEVA.Historian.Client.Wcf;
|
||||
/// <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:
|
||||
/// Per the captured analog 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)
|
||||
/// 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
|
||||
/// </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
|
||||
@@ -35,14 +38,35 @@ internal static class HistorianTagWriteProtocol
|
||||
{
|
||||
private const byte CompactAsciiMarker = 0x09;
|
||||
|
||||
private static readonly byte[] AnalogHeader =
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="HistorianWcfTagClient"/> MapDataType for the read path.
|
||||
/// </summary>
|
||||
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];
|
||||
/// <summary>Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.</summary>
|
||||
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
|
||||
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
|
||||
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
|
||||
// <InBuff> 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="GetAnalogDataTypeCode"/> 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.
|
||||
/// </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="dataType">Native data type — Float by default for backward compat.</param>
|
||||
/// <param name="dateCreatedUtc">DateCreated FILETIME (caller passes <see cref="DateTime.UtcNow"/>).</param>
|
||||
/// <param name="minEU">Engineering-units lower bound.</param>
|
||||
/// <param name="maxEU">Engineering-units upper bound.</param>
|
||||
/// <param name="minRaw">Raw lower bound.</param>
|
||||
/// <param name="maxRaw">Raw upper bound.</param>
|
||||
/// <param name="storageRateMs">StorageRate in milliseconds.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ internal static class HistorianWcfAuthChainHelper
|
||||
private const byte NativeClientType = 4;
|
||||
public const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
|
||||
public const uint NativeIntegratedEventConnectionMode = 0x501;
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IHistoryServiceContract2, OpenConnectionContext>? additionalSetup = null)
|
||||
{
|
||||
ChannelFactory<IHistoryServiceContract2> historyFactory = new(historyBinding, historyEndpoint);
|
||||
historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfHistAddressingBehavior());
|
||||
if (HistorianWcfMessageCaptureBehavior.IsEnabled)
|
||||
{
|
||||
historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfMessageCaptureBehavior());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <remarks>
|
||||
/// Forces an explicit <c>wsa:To</c> URI on every outgoing message. Native captures
|
||||
/// of EnsT2 / DelT include <c>net.pipe://localhost/Hist</c> 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.
|
||||
/// </remarks>
|
||||
[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) { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <remarks>
|
||||
/// Reverse-engineering aid: when the env var <c>AVEVA_HISTORIAN_SDK_WIRE_CAPTURE</c> 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
|
||||
/// <c>instrument-wcf-{write,read}message</c> native captures and diff offset-by-offset to
|
||||
/// isolate SDK-vs-native differences. NEVER enable in production.
|
||||
/// </remarks>
|
||||
[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 ?? "<no-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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -403,14 +403,28 @@ internal sealed class HistorianWcfReadOrchestrator
|
||||
};
|
||||
}
|
||||
|
||||
private static uint MapRetrievalModeToQueryType(Models.RetrievalMode mode) => mode switch
|
||||
/// <summary>
|
||||
/// QueryType wire value matches the native <c>ArchestrA.HistorianRetrievalMode</c> enum
|
||||
/// ordinal exactly — verified 2026-05-04 by probing every mode through the
|
||||
/// <c>instrument-wcf-writemessage</c> capture pipeline and reading the QueryType uint32
|
||||
/// at offset 2 of <c>pRequestBuff</c>:
|
||||
/// <code>
|
||||
/// 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
|
||||
/// </code>
|
||||
/// The public <see cref="Models.RetrievalMode"/> enum mirrors the native order, so the
|
||||
/// mapping reduces to <c>(uint)mode</c>. Prior version mapped <c>Cyclic</c> to 4
|
||||
/// (BestFit's value) and threw for everything outside the four common modes.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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 ? "<null>" : Convert.ToHexString(outBuf))} ErrLen={errBuf?.Length ?? -1} ErrHex={(errBuf is null ? "<null>" : Convert.ToHexString(errBuf))}");
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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 ? "<null>" : Convert.ToHexString(status))} ErrorSize={errorSize} ErrorLen={errorBuffer?.Length ?? -1} ErrorHex={(errorBuffer is null ? "<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 =
|
||||
|
||||
Reference in New Issue
Block a user