feat(wcf): C2 spike + ConnectViaAddress/connmode — WCF transport viable, rows server-gated #1

Merged
dohertj2 merged 16 commits from feat/c2-wcf-event-spike into main 2026-06-26 06:48:03 -04:00
9 changed files with 226 additions and 18 deletions
Showing only changes of commit 5a7a28872b - Show all commits
+2 -2
View File
@@ -18,9 +18,9 @@ Reads (the original required surface, all working live as of 2026-05-04):
Writes (added 2026-05-04 by explicit user request — do not extend further without one): 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`/`MinRaw`/`MaxRaw` all round-trip into the DB. By default `ApplyScaling=false` and the server mirrors MinRaw→MinEU and sets `AnalogTag.Scaling=0`; set `ApplyScaling=true` on the definition to persist distinct raw bounds with `AnalogTag.Scaling=1`. The wire encoding is the trailer's second byte (`FE 00` vs `FE 01`). - `EnsureTagAsync` for analog types: Float, Double, Int2, Int4, UInt4, **Int8, UInt8** (all live-verified end-to-end; `Int8`/`UInt8` added 2026-06-25 — same analog `CTagMetadata` layout, type codes `0x19`/`0x39`). **`UInt1` is NOT supported**: the server accepts `EnsureTags(UInt1)` but stores a *degenerate* analog tag (`GetTagInfosFromName` returns a 31-byte stub — descriptor type byte `0x00`, no GUID), so the write fails on the `GetTagInfo` path; re-gated fail-closed. SingleByteString/DoubleByteString and special/event types require a different (non-analog) path and are intentionally not supported. `MinEU`/`MaxEU`/`MinRaw`/`MaxRaw` all round-trip into the DB. By default `ApplyScaling=false` and the server mirrors MinRaw→MinEU and sets `AnalogTag.Scaling=0`; set `ApplyScaling=true` on the definition to persist distinct raw bounds with `AnalogTag.Scaling=1`. The wire encoding is the trailer's second byte (`FE 00` vs `FE 01`).
- `DeleteTagAsync` - `DeleteTagAsync`
- `AddHistoricalValuesAsync` (added 2026-06-21 by explicit user request — M3 historical/backfill writes). **gRPC-only** (`HistorianTransport.RemoteGrpc`); non-gRPC transports throw `ProtocolEvidenceMissingException`. Reverse-engineered by capturing the native 2023 R2 client: the historical write rides `HistoryService.AddStreamValues` with an "ON" storage-sample buffer (`HistorianHistoricalWriteProtocol`, golden-tested), NOT the TransactionService `AddNonStreamValues` path the static decompile suggested. Orchestrator (`HistorianGrpcHistoricalWriteOrchestrator`): write-enabled session → `GetTagInfosFromName` (resolves the per-tag GUID = the tag-info `TypeId`, and maps the data type via `MapDataType`) → `AddStreamValues`. Tag must pre-exist (`EnsureTagAsync`). Supports all five analog types `EnsureTagAsync` does — **Float, Double, Int2, Int4, UInt4** (all captured live + golden-tested + write/read-back validated). The 4-byte value descriptor is constant (`C0 10 01 00`); the value is `u32(0) + native-width value` (float32 / double64 / int16 / int32 / uint32) selected by the tag's declared type. Other tag types throw `ProtocolEvidenceMissingException`. Live-validated end-to-end against the 2023 R2 server. The D2/`AddS2` cache gate (err 129) does NOT block the primed 2023 R2 client. See `docs/plans/revision-write-path.md` §"R3.1 CAPTURED". - `AddHistoricalValuesAsync` (added 2026-06-21 by explicit user request — M3 historical/backfill writes). **gRPC-only** (`HistorianTransport.RemoteGrpc`); non-gRPC transports throw `ProtocolEvidenceMissingException`. Reverse-engineered by capturing the native 2023 R2 client: the historical write rides `HistoryService.AddStreamValues` with an "ON" storage-sample buffer (`HistorianHistoricalWriteProtocol`, golden-tested), NOT the TransactionService `AddNonStreamValues` path the static decompile suggested. Orchestrator (`HistorianGrpcHistoricalWriteOrchestrator`): write-enabled session → `GetTagInfosFromName` (resolves the per-tag GUID = the tag-info `TypeId`, and maps the data type via `MapDataType`) → `AddStreamValues`. Tag must pre-exist (`EnsureTagAsync`). Supports the analog types `EnsureTagAsync` does — **Float, Double, Int2, Int4, UInt4, Int8, UInt8** (all captured live + golden-tested + write/read-back validated; `Int8`/`UInt8` added 2026-06-25, value = native-width LE int64/uint64). The 4-byte value descriptor is constant (`C0 10 01 00`); the value is `u32(0) + native-width value` (float32 / double64 / int16 / int32 / uint32 / int64 / uint64) selected by the tag's declared type. Other tag types throw `ProtocolEvidenceMissingException` (incl. `UInt1` — server-degenerate, see `EnsureTagAsync` above). Live-validated end-to-end against the 2023 R2 server. The D2/`AddS2` cache gate (err 129) does NOT block the primed 2023 R2 client. See `docs/plans/revision-write-path.md` §"R3.1 CAPTURED".
- `SendEventAsync` (M2 event-send; added by explicit user request). Appends a single `HistorianEvent` to the built-in `CM_EVENT` tag, readable back via `ReadEventsAsync` / `v_AlarmEventHistory2`. Works on **both transports**, routed by `HistorianClientOptions.Transport`: WCF runs Open2 event-mode (`0x501`) → CM_EVENT registration (RTag2 + EnsT2) → `AddS2` (`AddStreamValues2`); gRPC (`RemoteGrpc`, `HistorianGrpcEventWriteOrchestrator`, added 2026-06-23) runs the v8 Event `OpenConnection` (ExchangeKey ECDH) → CM_EVENT registration → `HistoryService.AddStreamValues`. **Both carry the same `"OS"` (0x534F) event VTQ buffer** (`HistorianEventWriteProtocol`, the managed `PackToVtq` equivalent) — there is NO distinct event-send RPC and it is NOT the historical write's `"ON"` buffer (captured live from the native 2023 R2 client; the write-enabled Event open is byte-identical to the read-only one). The gRPC path is **live-validated end-to-end** (send → `BSuccess` → event reads back from the server). Only **original events** (`RevisionVersion = 0`) with **string-valued properties** have a captured encoding; revision/update/delete events and non-string property values throw `ProtocolEvidenceMissingException`. Registration buffers are golden-tested against the live capture (`GrpcEventSendProtocolTests`); gated live test `SendEventAsync_OverGrpc_AcceptsEvent` (opt-in `HISTORIAN_GRPC_EVENT_SEND=1`). - `SendEventAsync` (M2 event-send; added by explicit user request). Appends a single `HistorianEvent` to the built-in `CM_EVENT` tag, readable back via `ReadEventsAsync` / `v_AlarmEventHistory2`. Works on **both transports**, routed by `HistorianClientOptions.Transport`: WCF runs Open2 event-mode (`0x501`) → CM_EVENT registration (RTag2 + EnsT2) → `AddS2` (`AddStreamValues2`); gRPC (`RemoteGrpc`, `HistorianGrpcEventWriteOrchestrator`, added 2026-06-23) runs the v8 Event `OpenConnection` (ExchangeKey ECDH) → CM_EVENT registration → `HistoryService.AddStreamValues`. **Both carry the same `"OS"` (0x534F) event VTQ buffer** (`HistorianEventWriteProtocol`, the managed `PackToVtq` equivalent) — there is NO distinct event-send RPC and it is NOT the historical write's `"ON"` buffer (captured live from the native 2023 R2 client; the write-enabled Event open is byte-identical to the read-only one). The gRPC path is **live-validated end-to-end** (send → `BSuccess` → event reads back from the server). Only **original events** (`RevisionVersion = 0`) with **string-valued properties** have a captured encoding; revision/update/delete events and non-string property values throw `ProtocolEvidenceMissingException`. Registration buffers are golden-tested against the live capture (`GrpcEventSendProtocolTests`); gated live test `SendEventAsync_OverGrpc_AcceptsEvent` (opt-in `HISTORIAN_GRPC_EVENT_SEND=1`).
`AddS2` (streaming process-sample writes for user tags) remains architecturally blocked — the server cache only ingests from configured IOServers/ApplicationServer pipelines. Do not add streaming write-samples support. (`AddHistoricalValuesAsync` is the distinct *non-streamed original/backfill* path and is supported.) `AddS2` (streaming process-sample writes for user tags) remains architecturally blocked — the server cache only ingests from configured IOServers/ApplicationServer pipelines. Do not add streaming write-samples support. (`AddHistoricalValuesAsync` is the distinct *non-streamed original/backfill* path and is supported.)
@@ -0,0 +1,45 @@
# 2023 R2 gRPC Interface-Version Integers (C3a)
**Captured:** 2026-06-25
**Transport:** 2023 R2 gRPC (h2c, unauthenticated `GetInterfaceVersion` RPCs — no credentials required)
**Status:** LIVE — integers captured from a real AVEVA Historian 2023 R2 server.
## Captured Values
| Service | UiVersion / Version | UiError / Error | Notes |
|---------------|---------------------|-----------------|----------------------------------------------------|
| History | 12 | 0 | Matches `HistoryInterfaceVersionGrpc2023R2 = 12` |
| Retrieval | 4 | 0 | Matches `RetrievalInterfaceVersion = 4` (unchanged from 2020) |
| Transaction | 2 | 0 | Matches `TransactionInterfaceVersion = 2` (unchanged from 2020) |
| Status | 4 | 0 | Reachability-only; version integer is not version-gated (see note) |
> **Field-name note:** The History, Retrieval, and Status proto responses use `UiError`/`UiVersion` fields.
> The Transaction response uses `Error`/`Version` (different naming convention in the proto). Both are
> captured correctly; the table uses a unified column header for readability.
> **Status note:** `StatusService.GetStatusInterfaceVersion` returned UiVersion=4, UiError=0 on the live
> 2023 R2 server. This differs from the historical 0 observed on 2020 WCF — both are reachability-only.
> Status is classified as reachability-only: its version integer carries no semantic meaning for the
> SDK's byte serializers, so its UiVersion is not gated and not asserted in tests.
## Evidence Test
`tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs`
`GrpcInterfaceVersions_LiveServer_MatchAcceptedSet` reads these four RPCs live and asserts:
- `history.UiError == 0` and `history.UiVersion ∈ {11, 12}`
- `retrieval.UiError == 0` and `retrieval.UiVersion == 4`
- `transaction.Error == 0` and `transaction.Version == 2`
- `status.UiError == 0` (version not asserted)
The test skips silently when `HISTORIAN_GRPC_HOST` is absent.
## Gap Closed
This document closes the **C3a** gap: "2023 R2 gRPC server-version integers not yet captured."
Prior to this capture, the `HistorianServerVersionGate` accepted History=12, Retrieval=4, and
Transaction=2 on the basis that they were inferred/expected-to-be-unchanged. All four integers are
now confirmed from a live 2023 R2 server over the gRPC transport; no widening of `AcceptedVersions`
is required (all captured values were already accepted).
The 2020 WCF baseline (History=11, Retrieval=4, Transaction=2) was captured earlier via the
`wcf-probe` command and is documented in `wcf-probe-remote-latest.json` and `wcf-contract-evidence.md`.
@@ -29,8 +29,9 @@ internal enum HistorianServiceInterface
/// <item>Retrieval (<c>Retr</c>) interface version = 4</item> /// <item>Retrieval (<c>Retr</c>) interface version = 4</item>
/// <item>Transaction (<c>Trx</c>) interface version = 2</item> /// <item>Transaction (<c>Trx</c>) interface version = 2</item>
/// </list> /// </list>
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> returns 0 (not a real /// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> is not a real version (0 on
/// version), so the Status interface is validated for reachability only, never value. /// 2020 WCF, 4 on 2023 R2 gRPC) — it carries no meaning for the byte serializers either way — so
/// the Status interface is validated for reachability only, never value.
/// ///
/// A 2023 R2 gRPC server reports History interface version 12 even though it carries the /// A 2023 R2 gRPC server reports History interface version 12 even though it carries the
/// same proven 2020 native buffers. That value is captured and accepted (see /// same proven 2020 native buffers. That value is captured and accepted (see
@@ -48,14 +49,19 @@ internal static class HistorianServerVersionGate
/// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with /// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with
/// the 2020 version 11 — the OpenConnection3 v6 / token / DataQueryRequest / row buffers are /// the 2020 version 11 — the OpenConnection3 v6 / token / DataQueryRequest / row buffers are
/// byte-identical — confirmed by a live end-to-end gRPC read against a real 2023 R2 server /// byte-identical — confirmed by a live end-to-end gRPC read against a real 2023 R2 server
/// (2026-06-21). So both 11 and 12 are accepted for History. (Retrieval reported 4, matching /// (2026-06-21). So both 11 and 12 are accepted for History.
/// the 2020 value, so it needs no widening.) ///
/// Retrieval=4, Transaction=2, and Status UiError=0 are now confirmed captured live over the
/// 2023 R2 gRPC transport (2026-06-25, unauthenticated GetInterfaceVersion RPCs); see
/// <c>docs/reverse-engineering/grpc-interface-versions.md</c>. All captured values were already
/// accepted — no widening of <see cref="AcceptedVersions"/> was required.
/// </summary> /// </summary>
public const uint HistoryInterfaceVersionGrpc2023R2 = 12; public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
/// <summary> /// <summary>
/// True when the service interface reports a meaningful version that should be matched. /// True when the service interface reports a meaningful version that should be matched.
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0). /// Status is reachability-only (its <c>GetInterfaceVersion</c> is not a real version —
/// 0 on 2020 WCF, 4 on 2023 R2 gRPC).
/// </summary> /// </summary>
public static bool IsValueGated(HistorianServiceInterface service) => service switch public static bool IsValueGated(HistorianServiceInterface service) => service switch
{ {
@@ -29,9 +29,12 @@ namespace AVEVA.Historian.Client.Wcf;
/// +0x2A value bytes, native width by tag type: /// +0x2A value bytes, native width by tag type:
/// Float → Float32(4) · Double → Float64(8) · Int2 → Int16(2) /// Float → Float32(4) · Double → Float64(8) · Int2 → Int16(2)
/// Int4 → Int32(4) · UInt4 → UInt32(4) /// Int4 → Int32(4) · UInt4 → UInt32(4)
/// Int8 → Int64(8) · UInt8 → UInt64(8)
/// </code> /// </code>
/// ///
/// Captured for the five analog types <c>EnsureTagAsync</c> supports (Float/Double/Int2/Int4/UInt4). /// Live-captured for Float/Double/Int2/Int4/UInt4; Int8/UInt8 mirror the captured
/// Double value layout (8 LE bytes) and are live-proven (2026-06-25). UInt1 is re-gated:
/// the historian accepts EnsureTags(UInt1) but stores a degenerate tag — writes would fail.
/// Other tag types have no captured value encoding and are rejected. /// Other tag types have no captured value encoding and are rejected.
/// </remarks> /// </remarks>
internal static class HistorianHistoricalWriteProtocol internal static class HistorianHistoricalWriteProtocol
@@ -52,7 +55,10 @@ internal static class HistorianHistoricalWriteProtocol
/// <paramref name="tagGuid"/> is the per-tag GUID (from the gRPC tag-info read), /// <paramref name="tagGuid"/> is the per-tag GUID (from the gRPC tag-info read),
/// <paramref name="dataType"/> is the tag's declared analog type (selects the value width), and /// <paramref name="dataType"/> is the tag's declared analog type (selects the value width), and
/// <paramref name="receivedTimeUtc"/> is the storage/received timestamp the orchestrator stamps. /// <paramref name="receivedTimeUtc"/> is the storage/received timestamp the orchestrator stamps.
/// Throws <see cref="ProtocolEvidenceMissingException"/> for tag types without a captured encoding. /// Throws <see cref="ProtocolEvidenceMissingException"/> for tag types without a captured encoding
/// (including UInt1, which is re-gated — the historian creates a degenerate UInt1 analog tag).
/// Int8/UInt8 carry exact magnitude only up to 2^53 — the value API is <see langword="double"/>;
/// full 64-bit range is a separate follow-on.
/// </summary> /// </summary>
public static byte[] SerializeAddStreamValuesBuffer( public static byte[] SerializeAddStreamValuesBuffer(
Guid tagGuid, Guid tagGuid,
@@ -117,10 +123,22 @@ internal static class HistorianHistoricalWriteProtocol
BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value)); BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value));
return b; return b;
} }
case HistorianDataType.Int8:
{
byte[] b = new byte[8];
BinaryPrimitives.WriteInt64LittleEndian(b, checked((long)value));
return b;
}
case HistorianDataType.UInt8:
{
byte[] b = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(b, checked((ulong)value));
return b;
}
default: default:
throw new ProtocolEvidenceMissingException( throw new ProtocolEvidenceMissingException(
$"AddHistoricalValuesAsync has no captured value encoding for tag data type '{dataType}'. " + $"AddHistoricalValuesAsync has no captured value encoding for tag data type '{dataType}'. " +
"Captured types: Float, Double, Int2, Int4, UInt4."); "Captured types: Float, Double, Int2, Int4, UInt4, Int8, UInt8.");
} }
} }
@@ -58,9 +58,13 @@ internal static class HistorianTagWriteProtocol
]; ];
/// <summary> /// <summary>
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing /// Native CDataType wire codes per data type — captured by probing every type via
/// every type via instrument-wcf-writemessage. Matches the codes already documented /// instrument-wcf-writemessage. Matches the codes already documented in
/// in <see cref="HistorianWcfTagClient"/> MapDataType for the read path. /// <see cref="HistorianWcfTagClient"/> MapDataType for the read path; Int8/UInt8
/// reuse the same read-side codes (0x19/0x39).
/// UInt1 is excluded: live evidence shows the historian accepts EnsureTags(UInt1)
/// with success but stores a degenerate tag (null type descriptor) that subsequent
/// GetTagInfo cannot parse — not client-fixable on the analog path.
/// </summary> /// </summary>
public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
{ {
@@ -70,8 +74,10 @@ internal static class HistorianTagWriteProtocol
Models.HistorianDataType.UInt4 => 0x11, Models.HistorianDataType.UInt4 => 0x11,
Models.HistorianDataType.Int2 => 0x29, Models.HistorianDataType.Int2 => 0x29,
Models.HistorianDataType.Int4 => 0x31, Models.HistorianDataType.Int4 => 0x31,
Models.HistorianDataType.Int8 => 0x19,
Models.HistorianDataType.UInt8 => 0x39,
_ => throw new ProtocolEvidenceMissingException( _ => throw new ProtocolEvidenceMissingException(
$"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4."), $"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4, Int8, UInt8."),
}; };
private static readonly byte[] AnalogPadding16 = new byte[16]; private static readonly byte[] AnalogPadding16 = new byte[16];
@@ -118,8 +124,10 @@ internal static class HistorianTagWriteProtocol
/// <summary> /// <summary>
/// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float, /// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
/// Double, Int2, Int4, UInt4 — see <see cref="GetAnalogDataTypeCode"/> for the /// Double, Int2, Int4, UInt4; Int8/UInt8 supported via the read-side type codes
/// type-code mapping. Output matches the byte-for-byte capture for the same inputs. /// (pending live round-trip); UInt1 re-gated (historian creates a degenerate tag) —
/// 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 /// 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. /// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order.
/// </summary> /// </summary>
@@ -0,0 +1,103 @@
using AVEVA.Historian.Client.Grpc;
using GrpcHistory = ArchestrA.Grpc.Contract.History;
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction;
using Xunit.Abstractions;
namespace AVEVA.Historian.Client.Tests;
/// <summary>
/// Live evidence test (C3a): reads the four unauthenticated <c>GetInterfaceVersion</c> RPCs from a
/// real 2023 R2 Historian over gRPC and asserts the accepted version set. These RPCs run before any
/// credential exchange, so no HISTORIAN_USER / HISTORIAN_PASSWORD is required — only a reachable host.
///
/// Skips silently when <c>HISTORIAN_GRPC_HOST</c> is absent (offline / CI). The captured integers
/// are recorded in <c>docs/reverse-engineering/grpc-interface-versions.md</c> and close the C3a
/// "2023 R2 gRPC server-version integers not yet captured" gap.
/// </summary>
public sealed class GrpcInterfaceVersionEvidenceTests
{
private readonly ITestOutputHelper _output;
public GrpcInterfaceVersionEvidenceTests(ITestOutputHelper output) => _output = output;
[Fact]
public void GrpcInterfaceVersions_LiveServer_MatchAcceptedSet()
{
string host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? "";
if (string.IsNullOrWhiteSpace(host))
{
_output.WriteLine("SKIP: HISTORIAN_GRPC_HOST is not set — no live historian available.");
return;
}
HistorianClientOptions options = BuildOptions(host);
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
DateTime deadline = DateTime.UtcNow.Add(TimeSpan.FromSeconds(10));
GrpcHistory.GetInterfaceVersionResponse history =
new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel)
.GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, default);
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval =
new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel)
.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, default);
GrpcStatus.GetStatusInterfaceVersionResponse status =
new GrpcStatus.StatusService.StatusServiceClient(connection.Channel)
.GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, default);
GrpcTransaction.GetTransactionInterfaceVersionResponse transaction =
new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel)
.GetTransactionInterfaceVersion(new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, deadline, default);
_output.WriteLine($"History UiVersion={history.UiVersion} UiError={history.UiError}");
_output.WriteLine($"Retrieval UiVersion={retrieval.UiVersion} UiError={retrieval.UiError}");
_output.WriteLine($"Status UiVersion={status.UiVersion} UiError={status.UiError}");
// Note: Transaction response fields are named Error/Version (not UiError/UiVersion) per the proto.
_output.WriteLine($"Transaction Version={transaction.Version} Error={transaction.Error}");
// History: accepted set is {11 (2020 WCF), 12 (2023 R2 gRPC)}.
Assert.Equal(0u, history.UiError);
Assert.Contains(history.UiVersion, new uint[] { 11u, 12u });
// Retrieval: 4 on both 2020 WCF and 2023 R2 gRPC.
Assert.Equal(0u, retrieval.UiError);
Assert.Equal(4u, retrieval.UiVersion);
// Transaction: 2 (confirmed by live gRPC capture — see grpc-interface-versions.md).
// NOTE: the Transaction response proto uses Error/Version (not UiError/UiVersion).
Assert.Equal(0u, transaction.Error);
Assert.Equal(2u, transaction.Version);
// Status: reachability-only — assert UiError==0 only; UiVersion is not value-gated
// (observed 4 on 2023 R2 gRPC, 0 on 2020 WCF).
Assert.Equal(0u, status.UiError);
}
private static HistorianClientOptions BuildOptions(string host)
{
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
bool explicitCreds = !string.IsNullOrEmpty(user);
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
? parsed
: HistorianClientOptions.DefaultGrpcPort;
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
return new HistorianClientOptions
{
Host = host,
Port = port,
Transport = HistorianTransport.RemoteGrpc,
GrpcUseTls = tls,
AllowUntrustedServerCertificate = tls,
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
IntegratedSecurity = !explicitCreds,
UserName = user ?? string.Empty,
Password = password ?? string.Empty,
};
}
}
@@ -71,8 +71,9 @@ public sealed class HistorianServerVersionGateTests
[Fact] [Fact]
public void Validate_VerificationDisabled_NeverThrows() public void Validate_VerificationDisabled_NeverThrows()
{ {
// A wildly wrong version is tolerated when the operator opts out (e.g. bringing up a // A wildly wrong version is tolerated when the operator opts out. The 2023 R2 gRPC
// 2023 R2 gRPC server whose reported integers have not yet been captured). // integers are now captured live (see docs/reverse-engineering/grpc-interface-versions.md)
// and all accepted; this opt-out is a safety valve for some future, not-yet-captured value.
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false)); HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false));
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false)); HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false));
} }
@@ -62,6 +62,21 @@ public sealed class HistorianTagWriteProtocolTests
+ "09120052657465737453646B5772697465496E7432" + "09120052657465737453646B5772697465496E7432"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")] + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
// Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code byte differs.
[InlineData(
AVEVA.Historian.Client.Models.HistorianDataType.Int8,
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
"4E6703000100000004C6021900000000000000000000000000000000"
+ "09140052657465737453646B5772697465446F75626C65"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
[InlineData(
AVEVA.Historian.Client.Models.HistorianDataType.UInt8,
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
"4E6703000100000004C6023900000000000000000000000000000000"
+ "09140052657465737453646B5772697465446F75626C65"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes( public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
AVEVA.Historian.Client.Models.HistorianDataType dataType, AVEVA.Historian.Client.Models.HistorianDataType dataType,
string tagName, string tagName,
@@ -249,6 +264,9 @@ public sealed class HistorianTagWriteProtocolTests
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString)); () => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString));
Assert.Throws<ProtocolEvidenceMissingException>( Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1)); () => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1));
// UInt1 re-gated: historian creates a degenerate UInt1 analog tag (null type descriptor) — see pending notes.
Assert.Throws<ProtocolEvidenceMissingException>(
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.UInt1));
} }
[Fact] [Fact]
@@ -21,6 +21,11 @@ public sealed class WcfHistoricalWriteProtocolTests
"4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")] "4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")]
[InlineData(HistorianDataType.UInt4, 305419896d, [InlineData(HistorianDataType.UInt4, 305419896d,
"4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")] "4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")]
// Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the value bytes differ. Live round-trip confirms (gateway HistorianTypeRoundTripTests).
[InlineData(HistorianDataType.Int8, 100000d,
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a086010000000000")]
[InlineData(HistorianDataType.UInt8, 100001d,
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a186010000000000")]
public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex) public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex)
{ {
byte[] captured = Convert.FromHexString(capturedHex); byte[] captured = Convert.FromHexString(capturedHex);
@@ -47,6 +52,10 @@ public sealed class WcfHistoricalWriteProtocolTests
Assert.Throws<ProtocolEvidenceMissingException>(() => Assert.Throws<ProtocolEvidenceMissingException>(() =>
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer( HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
Guid.NewGuid(), HistorianDataType.SingleByteString, DateTime.UtcNow, 1.0, DateTime.UtcNow)); Guid.NewGuid(), HistorianDataType.SingleByteString, DateTime.UtcNow, 1.0, DateTime.UtcNow));
// UInt1 re-gated: historian creates a degenerate UInt1 analog tag (null type descriptor) — see pending notes.
Assert.Throws<ProtocolEvidenceMissingException>(() =>
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
Guid.NewGuid(), HistorianDataType.UInt1, DateTime.UtcNow, 1.0, DateTime.UtcNow));
} }
[Fact] [Fact]