108220c36b
Ship tag extended-property reads over the 2020 WCF aa/Retr/GetTepByNm op: HistorianClient.GetTagExtendedPropertiesAsync(tag) -> name/value pairs. String-handle op reached with the Open2 storage-session GUID formatted uppercase (same format that unlocked GETRP/GETHI/ExeC). Routed via the name-based native path (GetTagExtendedPropertiesByName, server-fetch flag), not the index-based TagQuery path. Evidence-backed findings from the capture: - GetTepByNm (and GetTgByNm) succeed with the uppercase handle -- further validates the resolved string-handle wall. - QTB (StartTagQuery) does NOT punch through: captured uppercase, it still fails server-side (CMdServer::StartActiveTagnamesQuery over the aahMetadataServer pipe) -- a metadata-server blocker, not handle format. - R1.6 (localized properties) has NO distinct op (only error-message/UI-text localization in the managed client); collapses into R1.5. Closed, not throwing. Wire format (golden-pinned, synthetic bytes -- no dev tag names committed): - 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. Adds: HistorianTagExtendedProperty model, HistorianTagExtendedPropertyProtocol (codec), HistorianWcfTagExtendedPropertyClient (orchestration), dialect + public API; golden WcfTagExtendedPropertyProtocolTests (4) + gated live test (HISTORIAN_TEP_TAG). Tooling: Capture-TagExtendedProperties.ps1, decode-tag-properties-capture.py, harness tag-extended-properties scenario. Docs: wcf-tag-extended-properties.md; roadmap R1.5 DONE / R1.6 collapsed; wall doc + memory updated with the QTB-server-side nuance. 228 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
224 lines
9.6 KiB
C#
224 lines
9.6 KiB
C#
using AVEVA.Historian.Client.Models;
|
|
using AVEVA.Historian.Client.Protocol;
|
|
using AVEVA.Historian.Client.Transport;
|
|
using AVEVA.Historian.Client.Wcf;
|
|
|
|
namespace AVEVA.Historian.Client;
|
|
|
|
public sealed class HistorianClient : IAsyncDisposable
|
|
{
|
|
private readonly HistorianClientOptions _options;
|
|
private readonly IHistorianTransportFactory _transportFactory;
|
|
private readonly Historian2020ProtocolDialect _protocol;
|
|
|
|
public HistorianClient(HistorianClientOptions options)
|
|
: this(options, TcpHistorianTransport.Factory)
|
|
{
|
|
}
|
|
|
|
internal HistorianClient(HistorianClientOptions options, IHistorianTransportFactory transportFactory)
|
|
{
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory));
|
|
_protocol = new Historian2020ProtocolDialect(_options);
|
|
}
|
|
|
|
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
|
string tag,
|
|
DateTime startUtc,
|
|
DateTime endUtc,
|
|
int maxValues,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxValues);
|
|
ValidateTimeRange(startUtc, endUtc);
|
|
|
|
return _protocol.ReadRawAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
|
|
}
|
|
|
|
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
|
|
string tag,
|
|
DateTime startUtc,
|
|
DateTime endUtc,
|
|
RetrievalMode mode,
|
|
TimeSpan interval,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
|
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(interval, TimeSpan.Zero);
|
|
ValidateTimeRange(startUtc, endUtc);
|
|
|
|
return _protocol.ReadAggregateAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
|
|
}
|
|
|
|
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
|
|
string tag,
|
|
IReadOnlyList<DateTime> timestampsUtc,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
|
ArgumentNullException.ThrowIfNull(timestampsUtc);
|
|
|
|
if (timestampsUtc.Count == 0)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<HistorianSample>>(Array.Empty<HistorianSample>());
|
|
}
|
|
|
|
return _protocol.ReadAtTimeAsync(tag, timestampsUtc, cancellationToken);
|
|
}
|
|
|
|
public IAsyncEnumerable<HistorianBlock> ReadBlocksAsync(
|
|
string tag,
|
|
DateTime startUtc,
|
|
DateTime endUtc,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
|
ValidateTimeRange(startUtc, endUtc);
|
|
return _protocol.ReadBlocksAsync(tag, startUtc, endUtc, cancellationToken);
|
|
}
|
|
|
|
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
|
DateTime startUtc,
|
|
DateTime endUtc,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ValidateTimeRange(startUtc, endUtc);
|
|
return _protocol.ReadEventsAsync(startUtc, endUtc, filter: null, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads events in the time window, server-filtered by a single predicate
|
|
/// (<paramref name="filter"/>) — e.g. <c>Type Equal "User.Write"</c> or
|
|
/// <c>Area Contains "Tank"</c>. The historian applies the filter and returns only matching
|
|
/// events. Filtering is a real server-side operation (live-verified: a non-matching predicate
|
|
/// returns zero events). Single string-valued predicates only; see <see cref="HistorianEventFilter"/>.
|
|
/// </summary>
|
|
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
|
DateTime startUtc,
|
|
DateTime endUtc,
|
|
HistorianEventFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
ValidateTimeRange(startUtc, endUtc);
|
|
return _protocol.ReadEventsAsync(startUtc, endUtc, filter, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a single <see cref="HistorianEvent"/> to the Historian's built-in CM_EVENT tag
|
|
/// over the WCF event pipeline (Open2 event mode → CM_EVENT registration → AddS2). The
|
|
/// event is appended to the historian's event history and is readable back via
|
|
/// <see cref="ReadEventsAsync"/> / the <c>v_AlarmEventHistory2</c> view. Only original
|
|
/// events (<see cref="HistorianEvent.RevisionVersion"/> = 0) with string-valued properties
|
|
/// are supported; other property value types and revision/update/delete events throw
|
|
/// <see cref="ProtocolEvidenceMissingException"/> until their wire encoding is captured.
|
|
/// </summary>
|
|
public Task<bool> SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(historianEvent);
|
|
return new HistorianWcfEventOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken);
|
|
}
|
|
|
|
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(filter);
|
|
return HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken);
|
|
}
|
|
|
|
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
|
return HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken);
|
|
}
|
|
|
|
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return _protocol.GetConnectionStatusAsync(cancellationToken);
|
|
}
|
|
|
|
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return _protocol.GetStoreForwardStatusAsync(cancellationToken);
|
|
}
|
|
|
|
public Task<string?> GetSystemParameterAsync(string name, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
return _protocol.GetSystemParameterAsync(name, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a named Historian <em>runtime</em> parameter (the live server state surface,
|
|
/// distinct from the configuration <see cref="GetSystemParameterAsync"/>). Returns the
|
|
/// string value, or null when the server reports no value. Single string-valued parameters
|
|
/// only (the evidence-backed surface); see <c>HistorianRuntimeParameterProtocol</c>.
|
|
/// </summary>
|
|
public Task<string?> GetRuntimeParameterAsync(string name, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
return _protocol.GetRuntimeParameterAsync(name, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the extended (user-defined) properties attached to a tag via the 2020 WCF
|
|
/// <c>GetTepByNm</c> op. Returns the property name/value pairs (empty when the tag has none).
|
|
/// String-valued properties only (the evidence-backed surface); other value variants throw
|
|
/// <see cref="ProtocolEvidenceMissingException"/>. See
|
|
/// <c>HistorianTagExtendedPropertyProtocol</c>.
|
|
/// </summary>
|
|
public Task<IReadOnlyList<HistorianTagExtendedProperty>> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tag);
|
|
return _protocol.GetTagExtendedPropertiesAsync(tag, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates or updates the named tag in the Historian Runtime database via
|
|
/// <c>EnsureTags2</c>. Currently only <see cref="HistorianDataType.Float"/> is
|
|
/// live-verified. Note: writing data values to the new tag (via a separate
|
|
/// AddStreamedValue/AddS2 path) is NOT supported by the SDK — see
|
|
/// <c>docs/plans/write-commands-reverse-engineering.md</c> for the architectural
|
|
/// finding.
|
|
/// </summary>
|
|
public Task<bool> EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(definition);
|
|
return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes the named tag via <c>DeleteTags</c>. **Known issue (2026-05-04):**
|
|
/// the SDK's DelT call returns true but the server-side cascading deletion does
|
|
/// not always complete (the row remains in <c>Runtime.dbo.Tag</c>). The
|
|
/// captured native flow's DelT removes the tag cleanly, so additional priming
|
|
/// or a side call between WCF DelT and server cascade is missing. Use the SMC
|
|
/// fallback to clean up sandbox tags until this is resolved.
|
|
/// </summary>
|
|
public Task<bool> DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
|
return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private static void ValidateTimeRange(DateTime startUtc, DateTime endUtc)
|
|
{
|
|
if (startUtc.ToUniversalTime() > endUtc.ToUniversalTime())
|
|
{
|
|
throw new ArgumentException("Start time must be less than or equal to end time.");
|
|
}
|
|
}
|
|
|
|
}
|