diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md
index 77525de..bc8dc3d 100644
--- a/docs/plans/hcal-roadmap.md
+++ b/docs/plans/hcal-roadmap.md
@@ -88,6 +88,15 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
> 2023R2-gRPC-only (see `wcf-historian-info.md`). Net: the **reachable 2020-WCF M1 read surface is
> complete**; what remains is config *writes* (M1c — gated on an explicit user request) and the
> gRPC/2023R2-only items (R1.3 timezone, R1.4 EventStorageMode — need a live 2023 R2 server).
+>
+> **Update 2026-06-21 (live 2023 R2 gRPC probe — both closed):** **R1.3 SHIPPED on gRPC** —
+> `GetServerTimeZoneAsync` returns a real zone ("Eastern Daylight Time") via
+> `StatusService.GetSystemTimeZoneName`; non-gRPC path fails closed
+> (`ProtocolEvidenceMissingException`). **R1.4 bounded out on gRPC too** — `GetHistorianInfo` is
+> named-value-only on the gRPC wire as well, `EventStorageMode` resolves under no name on either
+> `GetHistorianInfo` or `GetSystemParameter`, and the 518-byte struct is C++-HCAL-internal (filled
+> via native vtable+648, not the gRPC op). So **no gRPC/2023R2-specific reads remain open** — the
+> entire M1 read surface (2020 WCF + 2023 R2 gRPC) is now closed.
## Guiding principles
@@ -135,7 +144,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
|---|---|---|---|
| ~~R1.1~~ | ~~`ExecuteSqlCommandAsync`~~ | `Retrieval.ExecuteSqlCommand` (`ExeC`+`GetR`) | ✅ **DONE (2026-06-20), live-verified.** `ExecuteSqlCommandAsync(sql)` → `HistorianSqlResult` (columns + typed rows). String-handle op via the uppercase storage GUID. Chain: `Retr.GetV` prime → `ExeC(handle, sql, option=0, ref queryHandle)` → `GetR` loop (note: `GetR` returns **false even on success** — the stream is in `pResultBuff` regardless; false = final page). `GetR`'s `pResultBuff` is an **NRBF-serialized `DataTable`** (`SerializationFormat.Xml`: members `XmlSchema` + `XmlDiffGram`). BinaryFormatter is gone from .NET 10, so it's decoded read-only with `System.Formats.Nrbf` + `XDocument` (no BinaryFormatter). Shipped: `HistorianSqlResult`/`HistorianSqlColumn`/`HistorianSqlExecuteOption`, `HistorianSqlResultProtocol`, `HistorianWcfSqlClient`, golden `WcfSqlResultProtocolTests`, gated live tests. See `docs/reverse-engineering/wcf-exec-sql.md`. |
| ~~R1.2~~ | ~~`GetRuntimeParameterAsync`~~ | `Status.GetRuntimeParameter` (`aa/Stat/GETRP`) | ✅ **DONE (2026-06-20), live-verified.** Captured (`scripts/Capture-RuntimeParam.ps1`): GETRP is a **`string`-handle** op (GETHI's shape), but reachable from the managed client using the Open2 storage-session GUID as an **uppercase** string handle (`ToString("D").ToUpperInvariant()`). Returns `HistorianVersion` = `20,0,000,000` live. pRequestBuff = `54 67 01 00` + uint nameCount + per-name(uint charCount + UTF-16); pResponseBuff = version + uint resultCount + CRetVariant(`0x43` VT_BSTR + uint16 len + uint16 charCount + UTF-16). Single string-valued param only (multi-name framing inferred, not captured). Shipped: `HistorianClient.GetRuntimeParameterAsync(name)`; golden `WcfRuntimeParameterProtocolTests`. **Note:** GETRP punching through the string-handle wall with the uppercase storage GUID is a strong lead that GETHI/ExeC may be a handle-*format* issue — see `wcf-string-handle-wall.md` §Update. |
-| ~~R1.3~~ | ~~`GetServerTimeZoneAsync`~~ | `Status.GetSystemTimeZoneName` | ⛔ **gRPC/2023R2-only — re-confirmed 2026-06-21 from 3 angles.** (1) native `GetSystemTimeZoneName` is a stub (rc=0, empty) in the `GetServerTime` family; (2) Runtime DB has no timezone `SystemParameter` — the zone exists only as per-block `HistoryBlock.TimeZoneOffset`/`wwTimeZone` (DST-specific, SQL-only) + a `TimeZone` lookup table, `StorageShard.TimeZoneId`=NULL; (3) `GetSystemParameter` + `GETRP` return null/throw for every timezone candidate (only `HistorianVersion` works). The sole 2020 route is a SQL read via `ExecuteSqlCommand` (R1.1) — DST-specific, different mechanism. Build the real op only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. |
+| ~~R1.3~~ | `GetServerTimeZoneAsync` | `Status.GetSystemTimeZoneName` | ✅ **DONE on gRPC (2026-06-21), LIVE-VERIFIED** against the real 2023 R2 server — returns `"Eastern Daylight Time"`. `HistorianClient.GetServerTimeZoneAsync` routes over `RemoteGrpc` (`HistorianGrpcStatusClient.GetSystemTimeZoneNameAsync`, `uiHandle`-in/string-out, no buffer). The 2020 WCF op stays a client-side stub (rc=0, empty), so the non-gRPC path **throws `ProtocolEvidenceMissingException`** (fail-closed) rather than return an empty string. Golden message-shape + non-gRPC guardrail unit tests + gated live test. (2020-only routes — per-block `HistoryBlock.TimeZoneOffset`, SQL via R1.1 — remain DST-specific and are not this op.) |
> ✅ **String-handle "wall" RESOLVED (2026-06-20) — it was a handle-FORMAT bug.** R1.4/R1.5/R1.6
> (and R1.1) take a **`string` GUID handle**; the earlier "code 1/51 blocked" verdict came from
@@ -153,7 +162,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
### 1b. Bounded (decode one `bytes` payload; S–M each)
| ID | Capability | gRPC op | Payload to decode | Depends |
|---|---|---|---|---|
-| ~~R1.4~~ | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | ⛔ **BOUNDED OUT on 2020 (re-confirmed 2026-06-21).** GETHI is a **named-value** query reachable via the uppercase storage GUID, but **only `HistorianVersion` resolves** — the full 518-byte `HISTORIAN_INFO` struct (incl. `EventStorageMode`@514) is the 2023R2 HCAL-native/gRPC model. `EventStorageMode` has **no 2020 representation at all**: not a `SystemParameter` (only `EventStorageDuration`/`EventStorageLogPath`), not a DB column, and `GETRP`/`GetSystemParameter` return null/throw for it. The only 2020-reachable field (version) is already shipped via `GetSystemParameterAsync`/`GetRuntimeParameterAsync`, so a struct API would be hollow + misleading. Build the real op only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. | uppercase string handle |
+| ~~R1.4~~ | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | ⛔ **BOUNDED OUT — now confirmed on the 2023 R2 gRPC front door too (2026-06-21, live-probed).** The motivating field `EventStorageMode` is **not on the wire on either transport.** Live gRPC probe against the real 2023 R2 server: `GetHistorianInfo` is a **named-value** query exactly like 2020 WCF — only `HistorianVersion` resolves (→ `"23,1,000,000"` + `02 00 01 00` trailer); `EventStorageMode` + 7 name variants fail (`success=false`) on **both** `GetHistorianInfo` **and** `GetSystemParameter`. The 518-byte `HISTORIAN_INFO` struct (mode@514) is the **C++ HCAL in-memory model** (managed `HistorianAccess.GetHistorianInfo` fills it via a native **vtable+648** call, not the gRPC op — verified in the 2023 R2 decompile), derived outside the wire. The only wire-reachable field (version) is already shipped (`ProbeAsync`/`GetSystemParameterAsync`/`GetRuntimeParameterAsync`), so a struct API would be hollow + misleading. **Closes the prior "build against a live 2023 R2 server" caveat — done, and there is nothing to ship.** See `docs/reverse-engineering/wcf-historian-info.md`. | uppercase string handle |
| ~~R1.5~~ | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` (`GetTepByNm`) | ✅ **DONE (2026-06-20), live-verified.** `GetTagExtendedPropertiesAsync(tag)` → name/value pairs. String-handle op via the uppercase storage GUID; name-based path (`GetTagExtendedPropertiesByName`, not the QTB-gated TagQuery path). Request `tagNames` = `uint count` + per-name(`uint charCount`+UTF-16); response = `uint tagCount` + per-tag(marker + compact-ASCII name + `uint propCount` + per-prop(marker + compact-ASCII name + `0x43` VT_BSTR value) + trailer). Sequence-paged. Shipped: `HistorianTagExtendedPropertyProtocol`, golden `WcfTagExtendedPropertyProtocolTests`, gated live test. See `docs/reverse-engineering/wcf-tag-extended-properties.md`. | uppercase string handle |
| ~~R1.6~~ | Localized-property **read** | (no op) | ⛔ **No distinct op on 2020 — collapses into R1.5.** There is no `GetTagLocalizedPropertiesFromName`/`GetTlpByNm` or `GetTagLocalizedPropertiesByName` in `current/aahClientManaged.dll`; the only "localized" surfaces are error-message/UI-text localization. Extended properties (R1.5) are the user-defined tag-property read surface. Closed, not throwing. | — |
| ~~R1.7~~ | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | ✅ **DONE (2026-06-20), live-honored.** `ReadEventsAsync(start, end, HistorianEventFilter)`. The filter rides `StartEventQuery`'s `pRequestBuff` (captured via `EventQuery.AddEventFilter` + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: `ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0`. **REAL, not inert** (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via `AddEventFilterCondition`) framing not yet fully captured. See `HistorianEventFilter`, golden `WcfEventQueryProtocolTests`. | — |
@@ -212,14 +221,33 @@ byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event
*Goal: insert original historical VTQs (backfill), the path that is NOT the gated cache push.*
-| ID | Work | gRPC op |
-|---|---|---|
-| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` |
-| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end |
-| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) |
+> ⛔ **BLOCKED on 2020 WCF — re-confirmed by the D2 probe (2026-05-05), see
+> [`revision-write-path.md`](revision-write-path.md).** The premise above ("the path that is NOT
+> the gated cache push") was **disproved**: R3.1's op
+> (`Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End`) is the **same**
+> `ITransactionServiceContract2.AddNonStreamValuesBegin2` D2 probed, and over WCF it returns
+> `04 33 00 00 00` = `UnknownClient (51)` for every handle format **and** the full priming chain
+> (Stat/Hist/Retr/Trx GetV + UpdC3 + 6× GetSystemParameter + RTag2). Root cause (IL-walk:
+> `CClient.TransactionBegin` → `CHistStorageConnection.StartTransaction` →
+> `CStorageEngineConsoleClient.StartTransaction`): the real transaction rides a **shared-memory +
+> named-pipe** channel (`STransactPipeClient2` + `SCrtMemFile`) to `aaStorageEngine.exe`, separate
+> from WCF. The WCF Trx op is a server-side **relay** that requires a pre-existing storage-engine
+> pipe session, which no WCF op can establish. So **M3 over 2020 WCF is unimplementable as a
+> pure-managed SDK** — same architectural wall as R4.2 (revisions) and the `AddS2` cache gate.
+>
+> **Only remaining lever:** the **2023 R2 gRPC front door** (HCAL-native, no legacy storage-engine
+> pipe). Whether the gRPC services expose a non-streamed/revision write that bypasses the pipe is
+> **untested** — it needs the live 2023 R2 server + a native gRPC capture of the write op, then
+> decode/implement. Treat as on-demand (no current demand signal); the WCF path is closed.
-**Acceptance:** historical points inserted and read back. Document clearly where this
-differs from (gated) streaming sample writes.
+| ID | Work | gRPC op | Status |
+|---|---|---|---|
+| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` | ⛔ WCF blocked (storage-engine pipe — D2). gRPC: untested |
+| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end | ⛔ gated on R3.1 |
+| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) | ⛔ proven to share the same gate, not distinct |
+
+**Acceptance:** historical points inserted and read back. **WCF path closed (D2);** would require
+the gRPC write path (live 2023 R2 server + capture) to reopen.
---
@@ -271,12 +299,20 @@ Recommended first sprint: **CW-1 + M0 (R0.1–R0.6)** → a fully Windows-free,
gRPC client at today's capability. Second sprint: **M1a + M2** (cheap wins + the headline
event-send). M3/M4 as demand dictates.
+> **Status 2026-06-21:** sprints 1 + 2 are **complete** (M0 gRPC parity, the reachable M1 surface,
+> and M2 event-send all shipped + live-verified; remaining M1 items are evidence-bounded-out). The
+> reachable surface on the **available 2020 WCF infrastructure is exhausted** — every remaining
+> roadmap item is now either (a) blocked by the storage-engine-pipe architecture (**M3-WCF**, R4.2),
+> (b) **gRPC/2023R2-only** and needs the live 2023 R2 server for a native capture (R1.3 timezone,
+> R1.4 EventStorageMode, M3/revisions over gRPC), or (c) a HARD deferred subsystem (M4). No further
+> work lands without one of: a live-2023R2 capture session, or a customer-demand trigger.
+
## One-glance status
| Milestone | Tier | Effort | Value | When |
|---|---|---|---|---|
-| M0 gRPC parity + capture tooling | foundation | M | unblocks everything, Windows-free | **now** |
-| M1 cheap surface | TRIVIAL/BOUNDED | M–L | most remaining read/config | next |
-| M2 event send | CAPTURE | S–M | headline write capability | next |
-| M3 historical writes | BOUNDED | M | backfill | on demand |
-| M4 SF / revisions / redundancy | HARD | L×N | parity completeness | defer |
+| M0 gRPC parity + capture tooling | foundation | M | unblocks everything, Windows-free | ✅ **done** |
+| M1 cheap surface | TRIVIAL/BOUNDED | M–L | most remaining read/config | ✅ **done** (reachable surface; rest bounded out) |
+| M2 event send | CAPTURE | S–M | headline write capability | ✅ **done** |
+| M3 historical writes | BOUNDED | M | backfill | ⛔ WCF blocked (D2); gRPC = on-demand + live 2023R2 |
+| M4 SF / revisions / redundancy | HARD | L×N | parity completeness | defer (R4.2 = same pipe wall) |
diff --git a/docs/reverse-engineering/wcf-historian-info.md b/docs/reverse-engineering/wcf-historian-info.md
index 7efeeb6..7fa1a18 100644
--- a/docs/reverse-engineering/wcf-historian-info.md
+++ b/docs/reverse-engineering/wcf-historian-info.md
@@ -1,10 +1,13 @@
# GetHistorianInfo over 2020 WCF — GETHI is named-value-only (HCAL R1.4)
-**Status: ⚠ Bounded out on 2020 WCF (2026-06-20).** `GetHistorianInfoAsync` is **not shipped**:
-the one field that motivates it — `EventStorageMode` — is **not on the 2020 WCF wire**. The
-version field that GETHI *does* return over WCF is already exposed (`ProbeAsync`,
-`GetRuntimeParameterAsync("HistorianVersion")`), so there is nothing new to ship here without a
-2023 R2 gRPC server. This parallels R1.3 (`GetServerTimeZone`), which is likewise 2023R2-only.
+**Status: ⛔ Bounded out on BOTH 2020 WCF and 2023 R2 gRPC (2026-06-20; gRPC live-confirmed
+2026-06-21).** `GetHistorianInfoAsync` is **not shipped on any transport**: the one field that
+motivates it — `EventStorageMode` — is **not on the wire** on either transport (it lives only in
+the C++ HCAL's in-memory 518-byte struct, filled via a native vtable+648 call — see the §gRPC
+conclusion below). The version field GETHI *does* return is already exposed (`ProbeAsync`,
+`GetRuntimeParameterAsync("HistorianVersion")`), so there is nothing new to ship. Note: R1.3
+(`GetServerTimeZone`) — once paired with this as "2023R2-only" — **diverged**: it returns a real
+value over gRPC and **shipped** 2026-06-21 (`GetServerTimeZoneAsync`); R1.4 did not.
## What the capture showed
@@ -56,9 +59,19 @@ replay cannot observe or reproduce.
- **2020 WCF:** `GetHistorianInfoAsync` would add nothing over existing surface (version only) and
could not report a real `EventStorageMode` — so it is intentionally **not shipped** (no hollow
`Unsupported`-returning API; project discipline: don't ship misleading behavior).
-- **2023 R2 gRPC:** `Status.GetHistorianInfo` returns the full 518-byte `btHistorianInfo`; decode
- version@0 + `EventStorageMode`@514 there. Build + verify against a live 2023 R2 server. The
- `HistorianInfo` / `HistorianEventStorageMode` public types should land alongside that path.
+- **2023 R2 gRPC — LIVE-PROBED 2026-06-21, also bounded out.** The earlier expectation that
+ `Status.GetHistorianInfo` returns the full 518-byte `btHistorianInfo` over gRPC was **wrong**. On
+ the real 2023 R2 server (History iface 12), the gRPC `GetHistorianInfo` is the
+ **same named-value query** as 2020 WCF: only `HistorianVersion` resolves (→ `"23,1,000,000"` +
+ `02 00 01 00` trailer); `EventStorageMode` and seven name variants return `success=false` on
+ **both** `GetHistorianInfo` and `GetSystemParameter`. The 518-byte struct is **not on the gRPC
+ wire** — the 2023 R2 decompile confirms managed `HistorianAccess.GetHistorianInfo` fills it via a
+ **native vtable+648 HCAL call** (`IClientCommon*` + offset 648), not the gRPC op, so
+ `EventStorageMode` is derived inside the C++ HCAL outside the wire on gRPC exactly as on WCF.
+ **Conclusion: `GetHistorianInfoAsync` is not shipped on any transport** (the only wire-reachable
+ field, version, is already exposed). No `HistorianInfo` / `HistorianEventStorageMode` public type
+ was added. Probe: the (now-deleted) `GrpcStatusInfoProbeTests`; raw dump under
+ `artifacts/reverse-engineering/grpc-status-info-probe/` (gitignored).
## Tooling kept as RE aids
diff --git a/docs/reverse-engineering/wcf-status-localhost.md b/docs/reverse-engineering/wcf-status-localhost.md
index 4fbb24a..43ae8b6 100644
--- a/docs/reverse-engineering/wcf-status-localhost.md
+++ b/docs/reverse-engineering/wcf-status-localhost.md
@@ -99,3 +99,19 @@ deliverable as server ops on 2020.** The only 2020 route to the timezone is a SQ
mechanism than the roadmap's `Status.GetSystemTimeZoneName`. `EventStorageMode` has no 2020
representation at all (it is a 2023 R2 event-storage-architecture field). Deliver both only against a
live 2023 R2 gRPC server.
+
+## Resolution against the live 2023 R2 gRPC server (2026-06-21) — the two diverged
+
+Both ops were taken to the real 2023 R2 box (History iface 12) over the gRPC
+StatusService:
+
+- **R1.3 `GetServerTimeZoneAsync` — SHIPPED.** `StatusService.GetSystemTimeZoneName(uiHandle)`
+ returns the real Windows zone name **"Eastern Daylight Time"** (the 2020 stub returned empty).
+ `HistorianClient.GetServerTimeZoneAsync` routes over `RemoteGrpc`; the non-gRPC transports throw
+ `ProtocolEvidenceMissingException` (fail-closed, no empty-string lie). Golden message-shape +
+ non-gRPC guardrail unit tests + gated live test.
+- **R1.4 `GetHistorianInfoAsync` (`EventStorageMode`) — bounded out on gRPC too.** Over gRPC,
+ `GetHistorianInfo` is the **same named-value query** as 2020 WCF (only `HistorianVersion`
+ resolves); `EventStorageMode` + 7 variants fail on both `GetHistorianInfo` and
+ `GetSystemParameter`. The 518-byte struct is C++-HCAL-internal (native vtable+648), not on the
+ wire. Not shipped on any transport. See `wcf-historian-info.md`.
diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
index c0538f1..d245b88 100644
--- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
@@ -35,4 +35,38 @@ internal static class HistorianGrpcStatusClient
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
}
+
+ ///
+ /// Reads the Historian server's system time-zone name (roadmap item R1.3,
+ /// StatusService.GetSystemTimeZoneName). Unlike the 2020 WCF surface — where the native
+ /// GetSystemTimeZoneName is a client-side stub that returns an empty string — the 2023 R2
+ /// gRPC front door returns the real Windows time-zone display name (live-verified:
+ /// "Eastern Daylight Time"). Takes the transient uint client handle; the response carries
+ /// the value as a protobuf string with no opaque buffer to decode.
+ ///
+ public static Task GetSystemTimeZoneNameAsync(
+ HistorianClientOptions options,
+ CancellationToken cancellationToken)
+ => Task.Run(() => GetSystemTimeZoneName(options, cancellationToken), cancellationToken);
+
+ private static string? GetSystemTimeZoneName(HistorianClientOptions options, CancellationToken cancellationToken)
+ {
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
+ uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
+
+ var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
+ GrpcStatus.GetSystemTimeZoneNameResponse response = statusClient.GetSystemTimeZoneName(
+ new GrpcStatus.GetSystemTimeZoneNameRequest { UiHandle = clientHandle },
+ connection.Metadata,
+ DateTime.UtcNow.Add(options.RequestTimeout),
+ cancellationToken);
+
+ if (!(response.Status?.BSuccess ?? false))
+ {
+ return null;
+ }
+
+ string? value = response.StrSystemTimeZoneName;
+ return string.IsNullOrEmpty(value) ? null : value;
+ }
}
diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs
index b3bc40f..0cd4258 100644
--- a/src/AVEVA.Historian.Client/HistorianClient.cs
+++ b/src/AVEVA.Historian.Client/HistorianClient.cs
@@ -160,6 +160,20 @@ public sealed class HistorianClient : IAsyncDisposable
return _protocol.GetSystemParameterAsync(name, cancellationToken);
}
+ ///
+ /// Reads the Historian server's system time-zone name (e.g. "Eastern Daylight Time").
+ ///
+ /// Only the 2023 R2 front door exposes a real value;
+ /// the 2020 WCF GetSystemTimeZoneName is a client-side stub, so this throws
+ /// on the non-gRPC transports. Returns null when a
+ /// gRPC server reports no value.
+ ///
+ ///
+ public Task GetServerTimeZoneAsync(CancellationToken cancellationToken = default)
+ {
+ return _protocol.GetServerTimeZoneAsync(cancellationToken);
+ }
+
///
/// Reads a named Historian runtime parameter (the live server state surface,
/// distinct from the configuration ). Returns the
diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
index 079a2ff..7442edf 100644
--- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
+++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
@@ -69,6 +69,21 @@ internal sealed class Historian2020ProtocolDialect
: Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
}
+ public Task GetServerTimeZoneAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // 2023 R2 gRPC returns the real server time-zone name; the 2020 WCF
+ // GetSystemTimeZoneName is a client-side stub (empty value), so there is no evidence-backed
+ // value to return on that transport — fail closed rather than hand back an empty string.
+ if (!UseGrpc)
+ {
+ throw new ProtocolEvidenceMissingException("GetSystemTimeZoneName (2020 WCF stub — gRPC/2023R2 only)");
+ }
+
+ return HistorianGrpcStatusClient.GetSystemTimeZoneNameAsync(_options, cancellationToken);
+ }
+
public Task GetRuntimeParameterAsync(string name, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
index e932476..facb60c 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
@@ -66,6 +66,22 @@ public sealed class HistorianGrpcIntegrationTests
Assert.False(string.IsNullOrWhiteSpace(version));
}
+ [Fact]
+ public async Task GetServerTimeZoneAsync_OverGrpc_ReturnsZone()
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
+ if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
+ {
+ return;
+ }
+
+ // R1.3: gRPC StatusService.GetSystemTimeZoneName returns the real server zone (the 2020 WCF
+ // op is a stub). Live-verified value: "Eastern Daylight Time".
+ HistorianClient client = new(BuildOptions(host));
+ string? zone = await client.GetServerTimeZoneAsync(CancellationToken.None);
+ Assert.False(string.IsNullOrWhiteSpace(zone));
+ }
+
[Fact]
public async Task GetTagMetadataAsync_OverGrpc_ReturnsRequestedTag()
{
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs
index f50a0d8..795c849 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcTransportTests.cs
@@ -135,6 +135,39 @@ public sealed class HistorianGrpcTransportTests
Assert.Equal("20.0.000", response.StrParameterValue);
}
+ [Fact]
+ public void GetSystemTimeZoneNameMessages_CarryHandleAndValue_AsStatusClientExpects()
+ {
+ // R1.3 sends {uiHandle} and reads strSystemTimeZoneName when status succeeds — no buffer.
+ var request = ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameRequest.Parser.ParseFrom(
+ new ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameRequest { UiHandle = 11 }.ToByteArray());
+ Assert.Equal(11u, request.UiHandle);
+
+ var response = ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameResponse.Parser.ParseFrom(
+ new ArchestrA.Grpc.Contract.Status.GetSystemTimeZoneNameResponse
+ {
+ Status = new ArchestrA.Grpc.Contract.RequestStatus.Status { BSuccess = true },
+ StrSystemTimeZoneName = "Eastern Daylight Time"
+ }.ToByteArray());
+ Assert.True(response.Status.BSuccess);
+ Assert.Equal("Eastern Daylight Time", response.StrSystemTimeZoneName);
+ }
+
+ [Fact]
+ public async Task GetServerTimeZoneAsync_OnNonGrpcTransport_ThrowsEvidenceMissing()
+ {
+ // The 2020 WCF GetSystemTimeZoneName is a client-side stub (empty value); R1.3 only has an
+ // evidence-backed value on the gRPC front door, so the non-gRPC path must fail closed.
+ await using var client = new HistorianClient(new HistorianClientOptions
+ {
+ Host = "histserver",
+ Transport = HistorianTransport.LocalPipe,
+ IntegratedSecurity = true
+ });
+
+ await Assert.ThrowsAsync(() => client.GetServerTimeZoneAsync());
+ }
+
[Fact]
public void BuildTagNamesBuffer_EncodesCountThenLengthPrefixedUtf16Names()
{