From 7a3cd9b76e9d49631028ab3941333f3a9caa9aa6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 4 May 2026 14:52:13 -0400 Subject: [PATCH] Resolve write-path silent fails + expand EnsureTagAsync, RetrievalMode coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 33 +++++- .../Models/HistorianTagDefinition.cs | 34 ++++-- .../Wcf/HistorianTagWriteProtocol.cs | 99 +++++++++++++--- .../Wcf/HistorianWcfAuthChainHelper.cs | 12 ++ .../Wcf/HistorianWcfHistAddressingBehavior.cs | 39 ++++++ .../Wcf/HistorianWcfMessageCaptureBehavior.cs | 96 +++++++++++++++ .../Wcf/HistorianWcfReadOrchestrator.cs | 28 +++-- .../Wcf/HistorianWcfTagWriteOrchestrator.cs | 56 ++++++--- .../HistorianClientIntegrationTests.cs | 111 ++++++++++++++++-- .../HistorianRetrievalModeMappingTests.cs | 40 +++++++ .../HistorianTagWriteProtocolTests.cs | 110 +++++++++++++++-- .../Program.cs | 12 +- 12 files changed, 591 insertions(+), 79 deletions(-) create mode 100644 src/AVEVA.Historian.Client/Wcf/HistorianWcfHistAddressingBehavior.cs create mode 100644 src/AVEVA.Historian.Client/Wcf/HistorianWcfMessageCaptureBehavior.cs create mode 100644 tests/AVEVA.Historian.Client.Tests/HistorianRetrievalModeMappingTests.cs 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);