Compare commits
4 Commits
22e9c5e5f8
...
26ef5e5645
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ef5e5645 | |||
| 0e19adae68 | |||
| b0703ebf80 | |||
| c4b8d0dde4 |
@@ -9,15 +9,49 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
|
||||
> protocol serializer/parser + golden-byte unit test + an env-gated live integration
|
||||
> test against the local Historian.
|
||||
|
||||
## Progress (updated 2026-06-19)
|
||||
## Progress (updated 2026-06-21)
|
||||
|
||||
- ✅ **R0.6 version gate** — `HistorianServerVersionGate` + `HistorianClientOptions.VerifyServerInterfaceVersion`;
|
||||
fail-closed on connect, wired into both WCF and gRPC paths. Supported versions are
|
||||
evidence-based (Hist=11, Retr=4, Trx=2; Status reachability-only), captured from the
|
||||
live server. 10 unit tests.
|
||||
evidence-based (Hist=11/12, Retr=4, Trx=2; Status reachability-only), captured from the
|
||||
live server. History 12 (2023 R2 gRPC) accepted alongside 11 (buffer-compatible).
|
||||
- ✅ **CW-1 capture pipeline** — `ProtocolCaptureSanitizer` + `ProtocolFixtureWriter` +
|
||||
`capture-tag-info` CLI command; produces sanitized `fixtures/protocol/<op>/` golden files.
|
||||
11 unit tests. First fixture: `get-tag-info/analog-*.json`.
|
||||
- ✅ **gRPC auth handshake (read chain)** — LIVE-VERIFIED 2026-06-21 against a real 2023 R2
|
||||
server: `ReadRawAsync` over `RemoteGrpc` returns rows. Token loop routes to
|
||||
`StorageService.ValidateClientCredential`. Shared handshake extracted to
|
||||
`Grpc/HistorianGrpcHandshake` for reuse by the status/browse/metadata paths.
|
||||
- ✅ **R0.4 Probe over gRPC** — `Grpc/HistorianGrpcProbe` (History/Retrieval/Status
|
||||
`GetInterfaceVersion`); `ProbeAsync` routes over gRPC when `Transport==RemoteGrpc`.
|
||||
**LIVE-VERIFIED 2026-06-21** (no credentials required — runs before the auth loop).
|
||||
- ✅ **R0.3 System parameter over gRPC** — `Grpc/HistorianGrpcStatusClient.GetSystemParameterAsync`
|
||||
(`StatusService.GetSystemParameter`); routed in the dialect. Built + unit-tested + **LIVE-VERIFIED
|
||||
2026-06-21** against a real 2023 R2 server (returned `HistorianVersion`). Code path is the proven
|
||||
handshake + a single string-in/string-out RPC.
|
||||
- ✅ **R0.2 Tag metadata over gRPC** — `Grpc/HistorianGrpcTagClient.GetTagMetadataAsync`
|
||||
(`RetrievalService.GetTagInfosFromName`, the plural **string-handle** op). `GetTagMetadataAsync`
|
||||
routes over gRPC when `Transport==RemoteGrpc`. Request `btTagNames` = `uint count + per-name(uint
|
||||
charCount + UTF-16LE)` (golden-byte unit-tested); response `btTagInfos` = `uint count + CTagMetadata`
|
||||
records (reuses `ParseGetTagInfoResponse`); string handle = uppercase Open2 storage GUID. The 2020
|
||||
WCF string-handle wall does **not** apply on the gRPC front door (as predicted). **LIVE-VERIFIED
|
||||
2026-06-21** — `GetTagMetadataAsync` returned the requested tag + a valid data type.
|
||||
- 🟡 **R0.1 Browse over gRPC** — PARTIAL. Probed live 2026-06-21: the 2023 R2 gRPC front door does
|
||||
**not** hit the 2020 metadata-server-pipe wall. `RetrievalService.StartTagQuery` is **cracked** — it
|
||||
parses the filter as **OData** (`startswith(TagName,'Sys')`/`contains`/`eq`/empty succeed, returning
|
||||
the 8-byte `(queryHandle, tagCount)`; SQL-LIKE `%`/glob `*` → `ODataFilter: bad token`). Live: 220
|
||||
Sys* tags counted. **Remaining:** the `QueryTag` paging request format (constant native error
|
||||
type 4 / code 72 across guessed `btRequest` shapes) — needs a native capture, not guessing. Probe
|
||||
helpers (`HistorianGrpcTagClient.ProbeStartTagQuery`/`ProbeTagQuerySequence`) + a gated StartTagQuery
|
||||
test are committed. Full finding: `docs/reverse-engineering/grpc-tag-query-odata.md`.
|
||||
|
||||
> ℹ️ **Auth note (2026-06-21, resolved):** an apparent NTLM round-1 `SEC_E_LOGON_DENIED` blocker
|
||||
> turned out to be a **test-harness credential-parsing bug**, not a server/account/SDK issue — the
|
||||
> gitignored creds file stores **quoted** values (`"nam\user"`, `"pass"`), and the env-setup must
|
||||
> **strip surrounding quotes** before exporting `HISTORIAN_USER`/`HISTORIAN_PASSWORD`. With quotes
|
||||
> stripped, the domain account authenticates and the full read + system-param + probe chain passes
|
||||
> live. The round-failure diagnostic added during the hunt is kept
|
||||
> (`HistorianNativeHandshake.DescribeError` decodes the native error + hex/ASCII preview).
|
||||
|
||||
> ⚠️ **Live-verification constraint:** the local Historian is **2020** (WCF, port 32568) — the
|
||||
> 2023 R2 gRPC endpoint (32565) is absent. M0's gRPC routing (R0.1–R0.4) can be built and
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# R0.1 browse over gRPC — StartTagQuery takes an OData filter (2026-06-21)
|
||||
|
||||
Live-probed `RetrievalService.StartTagQuery` / `QueryTag` against a real **2023 R2** server over the
|
||||
gRPC front door (string-handle = uppercase Open2 storage GUID). Key result: **browse is feasible on
|
||||
2023 R2** — the 2020 WCF "metadata-server pipe" wall does **not** block here.
|
||||
|
||||
## StartTagQuery — CRACKED
|
||||
|
||||
`StartTagQuery(strHandle, btRequest)` where `btRequest` = the native
|
||||
`marker(26449) + version(1) + WriteHistorianString(filter)` buffer
|
||||
(`HistorianTagQueryProtocol.CreateStartTagQueryAttempt`). The server runs
|
||||
`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console` and
|
||||
**parses the filter string as OData** (not SQL-LIKE). Swept filters:
|
||||
|
||||
| filter | result |
|
||||
|---|---|
|
||||
| `startswith(TagName,'Sys')` | ✅ success, 8-byte response |
|
||||
| `contains(TagName,'Sys')` | ✅ success |
|
||||
| `TagName eq 'SysTimeSec'` | ✅ success |
|
||||
| `` (empty) | ✅ success (all tags) |
|
||||
| `Sys*` / `*` | ❌ `ODataFilter ... bad token` |
|
||||
| `TagName like 'Sys%'` / `Name like 'Sys%'` | ❌ rejected |
|
||||
|
||||
Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` pair
|
||||
(`ParseStartTagQueryResponse`). Live: `startswith(TagName,'Sys')` → tagCount = 220.
|
||||
|
||||
**Implication for the public API:** browse must translate the SDK's glob filter to OData —
|
||||
`*` → empty, `Pre*` → `startswith(TagName,'Pre')`, `*sub*` → `contains(TagName,'sub')`,
|
||||
exact → `TagName eq '...'`. (Escaping single-quotes in names still TBD.)
|
||||
|
||||
## QueryTag — OPEN (one capture away)
|
||||
|
||||
`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that should return the actual
|
||||
tag-name rows. Every `btRequest` shape tried returns a constant native error **type 4 / code 72**
|
||||
(independent of content: empty, count, column-name historian-string, `$select=TagName`,
|
||||
marker+version+name all give the same `04 48000000`). The constant code regardless of input means the
|
||||
request *framing* is wrong, not the field values — this needs a **native capture** of the real 2023 R2
|
||||
client driving a browse to recover the exact `QueryTag` `btRequest` (and the row framing in
|
||||
`btResonse`). Do not ship a guessed QueryTag request (project discipline: no guessed wire bytes).
|
||||
|
||||
Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`)
|
||||
and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result.
|
||||
@@ -0,0 +1,94 @@
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an
|
||||
/// existing <see cref="HistorianGrpcConnection"/> and returns the transient client handle used by
|
||||
/// the Retrieval/Status services. Extracted from <see cref="HistorianGrpcReadOrchestrator"/> so the
|
||||
/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain:
|
||||
/// <c>HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) →
|
||||
/// HistoryService.OpenConnection</c>. The byte payloads (OpenConnection3 v6 request, NTLM token
|
||||
/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf <c>bytes</c> fields.
|
||||
///
|
||||
/// See <see cref="HistorianGrpcReadOrchestrator"/> for the op-routing rationale (the Negotiate loop
|
||||
/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey).
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcHandshake
|
||||
{
|
||||
/// <summary>
|
||||
/// The handles produced by a successful OpenConnection. <see cref="ClientHandle"/> is the
|
||||
/// transient <c>uint</c> session token used by StartQuery/GetSystemParameter and the other
|
||||
/// uint-handle ops. <see cref="StorageSessionId"/> is the storage-session GUID used (formatted
|
||||
/// uppercase via <see cref="StringHandle"/>) by the string-handle ops
|
||||
/// (GetTagInfosFromName, GetTagExtendedPropertiesFromName, ExecuteSqlCommand, ...).
|
||||
/// </summary>
|
||||
internal readonly record struct Session(uint ClientHandle, Guid StorageSessionId)
|
||||
{
|
||||
/// <summary>The storage GUID in the uppercase "D" form the native string-handle ops require.</summary>
|
||||
public string StringHandle => StorageSessionId.ToString("D").ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Convenience overload for callers that only need the uint client handle.</summary>
|
||||
public static uint OpenAuthenticatedConnection(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
=> OpenSession(connection, options, cancellationToken).ClientHandle;
|
||||
|
||||
public static Session OpenSession(
|
||||
HistorianGrpcConnection connection,
|
||||
HistorianClientOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
|
||||
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, options);
|
||||
|
||||
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
{
|
||||
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
|
||||
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
bool success = response.Status?.BSuccess ?? false;
|
||||
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
||||
},
|
||||
contextKey,
|
||||
options,
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
|
||||
options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
||||
|
||||
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
||||
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
|
||||
if (!(open2.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
|
||||
}
|
||||
|
||||
(uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||
return new Session(clientHandle, storageSessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Grpc.Core;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC reachability probe (roadmap item R0.4). Mirrors <see cref="Wcf.HistorianWcfProbe"/>
|
||||
/// over the gRPC transport: it calls the unauthenticated <c>GetInterfaceVersion</c> RPC on the
|
||||
/// History, Retrieval, and Status services and applies the same success criteria. No credentials
|
||||
/// are required — these RPCs run before the SSPI/Negotiate token loop — so the probe works even
|
||||
/// when authentication is unavailable.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcProbe
|
||||
{
|
||||
public static async Task<bool> ProbeAsync(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return await Task.Run(() => Probe(options, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool Probe(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
DateTime deadline = DateTime.UtcNow.Add(options.ConnectTimeout > TimeSpan.Zero ? options.ConnectTimeout : TimeSpan.FromSeconds(5));
|
||||
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
GrpcHistory.GetInterfaceVersionResponse history = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval = retrievalClient.GetRetrievalInterfaceVersion(
|
||||
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetStatusInterfaceVersionResponse status = statusClient.GetStatusInterfaceVersion(
|
||||
new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||
|
||||
return history.UiError == 0
|
||||
&& history.UiVersion > 0
|
||||
&& retrieval.UiError == 0
|
||||
&& retrieval.UiVersion > 0
|
||||
&& status.UiError == 0;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
@@ -166,51 +164,7 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
}
|
||||
|
||||
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
|
||||
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
|
||||
|
||||
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
{
|
||||
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
|
||||
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
bool success = response.Status?.BSuccess ?? false;
|
||||
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
||||
},
|
||||
contextKey,
|
||||
_options,
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
|
||||
_options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
||||
|
||||
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
||||
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
||||
connection.Metadata,
|
||||
Deadline(),
|
||||
cancellationToken);
|
||||
|
||||
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
|
||||
if (!(open2.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
|
||||
}
|
||||
|
||||
(uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||
return clientHandle;
|
||||
}
|
||||
=> HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken);
|
||||
|
||||
private List<HistorianSample> RunQuery(
|
||||
HistorianGrpcConnection connection,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Grpc.Core;
|
||||
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC status client (roadmap item R0.3). Mirrors
|
||||
/// <see cref="Wcf.HistorianWcfStatusClient"/> over the gRPC transport: it opens an authenticated
|
||||
/// History session via <see cref="HistorianGrpcHandshake"/> and queries the StatusService for the
|
||||
/// resulting client handle. <c>GetSystemParameter</c> carries the parameter name as a protobuf
|
||||
/// string and returns the value string directly — there is no opaque native buffer to decode.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcStatusClient
|
||||
{
|
||||
public static Task<string?> GetSystemParameterAsync(
|
||||
HistorianClientOptions options,
|
||||
string parameterName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
|
||||
return Task.Run(() => GetSystemParameter(options, parameterName, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetSystemParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
|
||||
|
||||
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||
GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter(
|
||||
new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName },
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Text;
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// 2023 R2 gRPC tag-metadata client (roadmap item R0.2). Unlike the WCF singular
|
||||
/// <c>GetTagInfoFromName</c> (a <c>uint</c>-handle op), the gRPC front door exposes the plural
|
||||
/// <c>RetrievalService.GetTagInfosFromName</c> — a <b>string-handle</b> op keyed off the Open2
|
||||
/// storage-session GUID (uppercase). The request <c>btTagNames</c> buffer and response
|
||||
/// <c>btTagInfos</c> buffer carry the proven native encodings:
|
||||
/// <list type="bullet">
|
||||
/// <item>request <c>btTagNames</c> = <c>uint count</c> + per-name(<c>uint charCount</c> + UTF-16LE)</item>
|
||||
/// <item>response <c>btTagInfos</c> = <c>uint tagCount</c> + per-tag CTagMetadata record
|
||||
/// (the same record <see cref="HistorianTagQueryProtocol.ParseGetTagInfoResponse"/> decodes)</item>
|
||||
/// </list>
|
||||
/// The string-handle "wall" that blocks this op family on the 2020 WCF transport does not apply on
|
||||
/// the gRPC front door (different envelope/registration) — see
|
||||
/// <c>docs/reverse-engineering/wcf-string-handle-wall.md</c>.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcTagClient
|
||||
{
|
||||
public static Task<HistorianTagMetadata?> GetTagMetadataAsync(
|
||||
HistorianClientOptions options,
|
||||
string tag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return Task.Run(() => GetTagMetadata(options, tag, cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
private static HistorianTagMetadata? GetTagMetadata(HistorianClientOptions options, string tag, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] tagInfos = GetTagInfosRaw(options, [tag], cancellationToken);
|
||||
if (tagInfos.Length < 4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<HistorianTagInfoResponse> parsed = HistorianTagQueryProtocol.ParseGetTagInfoResponse(tagInfos);
|
||||
if (parsed.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
HistorianTagInfoResponse info = parsed[0];
|
||||
return new HistorianTagMetadata(
|
||||
Name: info.TagName,
|
||||
Key: info.TagKey,
|
||||
DataType: HistorianWcfTagClient.MapDataType(info.NativeDataTypeDescriptor),
|
||||
Description: info.Description ?? info.MetadataProvider,
|
||||
EngineeringUnit: info.EngineeringUnit ?? string.Empty,
|
||||
MinRaw: info.MinEU,
|
||||
MaxRaw: info.MaxEU);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a single <c>GetTagInfosFromName</c> call and returns the raw native <c>btTagInfos</c>
|
||||
/// response buffer. Internal so reverse-engineering probes can capture the framing.
|
||||
/// </summary>
|
||||
internal static byte[] GetTagInfosRaw(HistorianClientOptions options, IReadOnlyList<string> tags, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
byte[] requestBuffer = BuildTagNamesBuffer(tags);
|
||||
GrpcRetrieval.GetTagInfosFromNameResponse response = retrievalClient.GetTagInfosFromName(
|
||||
new GrpcRetrieval.GetTagInfosFromNameRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtTagNames = ByteString.CopyFrom(requestBuffer),
|
||||
UiSequence = 0
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
if (!(response.Status?.BSuccess ?? false))
|
||||
{
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
throw new InvalidOperationException($"gRPC GetTagInfosFromName failed (errorLen={error.Length}).");
|
||||
}
|
||||
|
||||
return response.BtTagInfos?.ToByteArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a StartTagQuery probe — whether the gRPC front door accepts the op.</summary>
|
||||
internal readonly record struct StartTagQueryProbeResult(bool Success, int ResponseLength, byte[] Response, int ErrorLength, byte[] Error);
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-engineering probe for R0.1 (browse): drives <c>RetrievalService.StartTagQuery</c> with
|
||||
/// the native filter request and reports whether the 2023 R2 front door accepts it (on 2020 WCF this
|
||||
/// op fails server-side on the aahMetadataServer pipe regardless of handle format).
|
||||
/// </summary>
|
||||
internal static StartTagQueryProbeResult ProbeStartTagQuery(HistorianClientOptions options, string filter, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
byte[] requestBuffer = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(filter).RequestBuffer;
|
||||
GrpcRetrieval.StartTagQueryResponse response = retrievalClient.StartTagQuery(
|
||||
new GrpcRetrieval.StartTagQueryRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
BtRequest = ByteString.CopyFrom(requestBuffer)
|
||||
},
|
||||
connection.Metadata,
|
||||
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||
cancellationToken);
|
||||
|
||||
bool success = response.Status?.BSuccess ?? false;
|
||||
byte[] resp = response.BtResponse?.ToByteArray() ?? [];
|
||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||
return new StartTagQueryProbeResult(success, resp.Length, resp, error.Length, error);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a full StartTagQuery → QueryTag → EndTagQuery probe.</summary>
|
||||
internal readonly record struct TagQuerySequenceResult(
|
||||
bool StartSuccess, uint QueryHandle, uint TagCount,
|
||||
bool QuerySuccess, byte[] QueryResponse, byte[] QueryError);
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-engineering probe for R0.1 (browse): runs the full StartTagQuery (OData filter) →
|
||||
/// QueryTag (paging) → EndTagQuery sequence and returns the raw QueryTag response buffer so its
|
||||
/// tag-name framing can be discovered. <paramref name="queryRequest"/> is the candidate
|
||||
/// QueryTag <c>btRequest</c> buffer (format unknown — swept by the probe).
|
||||
/// </summary>
|
||||
internal static TagQuerySequenceResult ProbeTagQuerySequence(
|
||||
HistorianClientOptions options, string odataFilter, byte[] queryRequest, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||
|
||||
byte[] startRequest = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(odataFilter).RequestBuffer;
|
||||
GrpcRetrieval.StartTagQueryResponse start = retrievalClient.StartTagQuery(
|
||||
new GrpcRetrieval.StartTagQueryRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(startRequest) },
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
if (!(start.Status?.BSuccess ?? false))
|
||||
{
|
||||
return new TagQuerySequenceResult(false, 0, 0, false, [], start.Status?.BtError?.ToByteArray() ?? []);
|
||||
}
|
||||
|
||||
byte[] startResp = start.BtResponse?.ToByteArray() ?? [];
|
||||
HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(startResp);
|
||||
|
||||
try
|
||||
{
|
||||
GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag(
|
||||
new GrpcRetrieval.QueryTagRequest
|
||||
{
|
||||
StrHandle = session.StringHandle,
|
||||
UiQueryHandle = parsed.QueryHandle,
|
||||
BtRequest = ByteString.CopyFrom(queryRequest)
|
||||
},
|
||||
connection.Metadata, Deadline(), cancellationToken);
|
||||
|
||||
return new TagQuerySequenceResult(
|
||||
true, parsed.QueryHandle, parsed.TagCount,
|
||||
query.Status?.BSuccess ?? false,
|
||||
query.BtResonse?.ToByteArray() ?? [],
|
||||
query.Status?.BtError?.ToByteArray() ?? []);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
retrievalClient.EndTagQuery(
|
||||
new GrpcRetrieval.EndTagQueryRequest { StrHandle = session.StringHandle, UiQueryHandle = parsed.QueryHandle },
|
||||
connection.Metadata, Deadline(), CancellationToken.None);
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE).</summary>
|
||||
internal static byte[] BuildTagNamesBuffer(IReadOnlyList<string> tags)
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||
|
||||
writer.Write((uint)tags.Count);
|
||||
foreach (string tag in tags)
|
||||
{
|
||||
writer.Write((uint)tag.Length);
|
||||
if (tag.Length > 0)
|
||||
{
|
||||
writer.Write(Encoding.Unicode.GetBytes(tag));
|
||||
}
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? await Grpc.HistorianGrpcProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false)
|
||||
: await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
||||
@@ -102,7 +104,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
||||
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
||||
return HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
|
||||
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||
? Grpc.HistorianGrpcTagClient.GetTagMetadataAsync(_options, tag, cancellationToken)
|
||||
: HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -64,7 +64,9 @@ internal sealed class Historian2020ProtocolDialect
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
|
||||
return UseGrpc
|
||||
? HistorianGrpcStatusClient.GetSystemParameterAsync(_options, name, cancellationToken)
|
||||
: Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> Missing<T>(
|
||||
|
||||
@@ -33,7 +33,7 @@ internal static class HistorianNativeHandshake
|
||||
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
|
||||
/// token (round byte + length + token). The WCF path maps this to
|
||||
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
|
||||
/// <c>HistoryService.ExchangeKey</c> (the renamed handshake op).
|
||||
/// <c>StorageService.ValidateClientCredential</c> (the op that kept the 2020 token framing).
|
||||
/// </summary>
|
||||
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
|
||||
|
||||
@@ -70,7 +70,8 @@ internal static class HistorianNativeHandshake
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.Length}).");
|
||||
throw new InvalidOperationException(
|
||||
$"Credential token round {round} rejected (errorLen={error.Length}).{DescribeError(error)}");
|
||||
}
|
||||
|
||||
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
|
||||
@@ -162,4 +163,32 @@ internal static class HistorianNativeHandshake
|
||||
int slash = userName.IndexOf('\\');
|
||||
return slash > 0 ? userName[(slash + 1)..] : userName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a diagnostic suffix for a rejected credential round: the decoded native error
|
||||
/// (type/code/name) plus a short hex + printable-ASCII preview of the server error buffer.
|
||||
/// Keeps secrets out — error buffers carry server status codes/messages, not credentials.
|
||||
/// </summary>
|
||||
private static string DescribeError(byte[] error)
|
||||
{
|
||||
if (error.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
HistorianNativeError? native = HistorianOpen2Protocol.TryReadNativeError(error);
|
||||
string nativePart = native is null
|
||||
? string.Empty
|
||||
: $" native(type={native.Type}, code={native.Code}{(native.Name is null ? string.Empty : $", {native.Name}")})";
|
||||
|
||||
ReadOnlySpan<byte> preview = error.AsSpan(0, Math.Min(error.Length, 64));
|
||||
string hex = Convert.ToHexString(preview);
|
||||
char[] ascii = new char[preview.Length];
|
||||
for (int i = 0; i < preview.Length; i++)
|
||||
{
|
||||
ascii[i] = preview[i] is >= 0x20 and < 0x7F ? (char)preview[i] : '.';
|
||||
}
|
||||
|
||||
return $"{nativePart} hex={hex} ascii=\"{new string(ascii)}\"";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace AVEVA.Historian.Client.Tests;
|
||||
/// credentials (live-confirmed against a real 2023 R2 server, 2026-06-21). An earlier revision
|
||||
/// routed the loop to ExchangeKey; this test fails if that regression returns.
|
||||
///
|
||||
/// It works by disassembling the IL of <c>HistorianGrpcReadOrchestrator</c> (and its
|
||||
/// It works by disassembling the IL of <c>HistorianGrpcHandshake</c> (and its
|
||||
/// compiler-generated nested closure types — the token-loop call lives inside a lambda) and
|
||||
/// collecting every method invoked.
|
||||
/// </summary>
|
||||
@@ -20,8 +20,10 @@ public sealed class HistorianGrpcHandshakeRoutingTests
|
||||
[Fact]
|
||||
public void Handshake_UsesValidateClientCredential_NotExchangeKey()
|
||||
{
|
||||
// The auth token loop lives in the shared handshake helper (reused by the read, status,
|
||||
// and future browse/metadata gRPC paths).
|
||||
HashSet<string> calledMethods = CollectCalledMethodNames(
|
||||
"AVEVA.Historian.Client.Grpc.HistorianGrpcReadOrchestrator");
|
||||
"AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake");
|
||||
|
||||
Assert.Contains("ValidateClientCredential", calledMethods);
|
||||
Assert.DoesNotContain("ExchangeKey", calledMethods);
|
||||
|
||||
@@ -12,6 +12,21 @@ namespace AVEVA.Historian.Client.Tests;
|
||||
/// </summary>
|
||||
public sealed class HistorianGrpcIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProbeAsync_OverGrpc_ReturnsTrue()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ProbeAsync calls the unauthenticated GetInterfaceVersion RPCs, so it succeeds even when
|
||||
// credentials are unavailable — no HISTORIAN_USER/PASSWORD required.
|
||||
HistorianClient client = new(BuildOptions(host));
|
||||
Assert.True(await client.ProbeAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow()
|
||||
{
|
||||
@@ -37,6 +52,64 @@ public sealed class HistorianGrpcIntegrationTests
|
||||
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSystemParameterAsync_OverGrpc_ReturnsValue()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(BuildOptions(host));
|
||||
string? version = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
|
||||
Assert.False(string.IsNullOrWhiteSpace(version));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_OverGrpc_ReturnsRequestedTag()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
string? tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(tag)
|
||||
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(BuildOptions(host));
|
||||
HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(tag, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(tag, metadata!.Name);
|
||||
// A real metadata record decodes to a known data type (descriptor passed MapDataType).
|
||||
Assert.True(Enum.IsDefined(metadata.DataType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTagQuery_OverGrpc_AcceptsODataFilter()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// R0.1 finding (2026-06-21): on the 2023 R2 gRPC front door the metadata-server pipe IS
|
||||
// reachable (unlike 2020 WCF) and StartActiveTagnamesQuery parses the filter as OData —
|
||||
// startswith/contains/eq succeed; SQL-LIKE "%"/glob "*" fail with "ODataFilter: bad token".
|
||||
// StartTagQuery returns the 8-byte (queryHandle, tagCount) response. The follow-on QueryTag
|
||||
// paging request format is not yet captured (see roadmap R0.1), so browse is not yet wired.
|
||||
HistorianClientOptions options = BuildOptions(host);
|
||||
var result = await Task.Run(() =>
|
||||
AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.ProbeStartTagQuery(options, "startswith(TagName,'Sys')", CancellationToken.None));
|
||||
|
||||
Assert.True(result.Success,
|
||||
$"StartTagQuery(OData) should succeed; errLen={result.ErrorLength} " +
|
||||
$"err=\"{System.Text.Encoding.ASCII.GetString(result.Error).Replace('\0', '.')}\"");
|
||||
Assert.Equal(8, result.ResponseLength); // (queryHandle, tagCount)
|
||||
}
|
||||
|
||||
private static HistorianClientOptions BuildOptions(string host)
|
||||
{
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
|
||||
@@ -100,6 +100,58 @@ public sealed class HistorianGrpcTransportTests
|
||||
Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterfaceVersionResponses_ExposeErrorAndVersion_AsProbeExpects()
|
||||
{
|
||||
// R0.4 ProbeAsync reads uiError/uiVersion off each service's GetInterfaceVersion response.
|
||||
// Pin that field mapping (success = uiError 0 + uiVersion > 0) via a protobuf round-trip.
|
||||
var history = GrpcHistory.GetInterfaceVersionResponse.Parser.ParseFrom(
|
||||
new GrpcHistory.GetInterfaceVersionResponse { UiError = 0, UiVersion = 12 }.ToByteArray());
|
||||
var retrieval = GetRetrievalInterfaceVersionResponse.Parser.ParseFrom(
|
||||
new GetRetrievalInterfaceVersionResponse { UiError = 0, UiVersion = 4 }.ToByteArray());
|
||||
|
||||
Assert.Equal(0u, history.UiError);
|
||||
Assert.Equal(12u, history.UiVersion);
|
||||
Assert.Equal(0u, retrieval.UiError);
|
||||
Assert.Equal(4u, retrieval.UiVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSystemParameterMessages_CarryHandleNameAndValue_AsStatusClientExpects()
|
||||
{
|
||||
// R0.3 sends {uiHandle, strParameterName} and reads strParameterValue when status succeeds.
|
||||
var request = ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest.Parser.ParseFrom(
|
||||
new ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest { UiHandle = 9, StrParameterName = "HistorianVersion" }.ToByteArray());
|
||||
Assert.Equal(9u, request.UiHandle);
|
||||
Assert.Equal("HistorianVersion", request.StrParameterName);
|
||||
|
||||
var response = ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse.Parser.ParseFrom(
|
||||
new ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse
|
||||
{
|
||||
Status = new ArchestrA.Grpc.Contract.RequestStatus.Status { BSuccess = true },
|
||||
StrParameterValue = "20.0.000"
|
||||
}.ToByteArray());
|
||||
Assert.True(response.Status.BSuccess);
|
||||
Assert.Equal("20.0.000", response.StrParameterValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagNamesBuffer_EncodesCountThenLengthPrefixedUtf16Names()
|
||||
{
|
||||
// R0.2 request framing: uint count + per-name(uint charCount + UTF-16LE). Golden bytes.
|
||||
byte[] buffer = AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.BuildTagNamesBuffer(["AB", "C"]);
|
||||
|
||||
byte[] expected =
|
||||
[
|
||||
0x02, 0x00, 0x00, 0x00, // count = 2
|
||||
0x02, 0x00, 0x00, 0x00, // "AB" char count = 2
|
||||
0x41, 0x00, 0x42, 0x00, // 'A','B' UTF-16LE
|
||||
0x01, 0x00, 0x00, 0x00, // "C" char count = 1
|
||||
0x43, 0x00 // 'C' UTF-16LE
|
||||
];
|
||||
Assert.Equal(expected, buffer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user