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:
Joseph Doherty
2026-05-04 14:52:13 -04:00
parent 200493c990
commit 7a3cd9b76e
12 changed files with 591 additions and 79 deletions
@@ -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<AVEVA.Historian.Client.Models.HistorianAggregateSample> 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]
@@ -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<ProtocolEvidenceMissingException>(
() => HistorianWcfReadOrchestrator.MapRetrievalModeToQueryType((RetrievalMode)999));
}
}
@@ -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<ProtocolEvidenceMissingException>(
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString));
Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1));
}
[Fact]
public void SerializeAnalogCTagMetadata_DifferentInputsProducesDifferentBytesInExpectedSlots()
{