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 ProbeAsync(CancellationToken cancellationToken = default) { return _options.Transport == HistorianTransport.RemoteGrpc ? await Grpc.HistorianGrpcProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false) : await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false); } public IAsyncEnumerable 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 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> ReadAtTimeAsync( string tag, IReadOnlyList timestampsUtc, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tag); ArgumentNullException.ThrowIfNull(timestampsUtc); if (timestampsUtc.Count == 0) { return Task.FromResult>(Array.Empty()); } return _protocol.ReadAtTimeAsync(tag, timestampsUtc, cancellationToken); } public IAsyncEnumerable 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 ReadEventsAsync( DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) { ValidateTimeRange(startUtc, endUtc); return _protocol.ReadEventsAsync(startUtc, endUtc, filter: null, cancellationToken); } /// /// Reads events in the time window, server-filtered by a single predicate /// () — e.g. Type Equal "User.Write" or /// Area Contains "Tank". 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 . /// public IAsyncEnumerable ReadEventsAsync( DateTime startUtc, DateTime endUtc, HistorianEventFilter filter, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(filter); ValidateTimeRange(startUtc, endUtc); return _protocol.ReadEventsAsync(startUtc, endUtc, filter, cancellationToken); } /// /// Sends a single 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 /// / the v_AlarmEventHistory2 view. Only original /// events ( = 0) with string-valued properties /// are supported; other property value types and revision/update/delete events throw /// until their wire encoding is captured. /// public Task SendEventAsync(HistorianEvent historianEvent, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(historianEvent); return new HistorianWcfEventOrchestrator(_options).SendEventAsync(historianEvent, cancellationToken); } /// /// Inserts historical (non-streamed original / backfill) values for an existing tag. Captured /// live from the native 2023 R2 client: the write rides HistoryService.AddStreamValues /// (an "ON" storage-sample buffer) over the gRPC front door — see /// docs/plans/revision-write-path.md §"R3.1 CAPTURED". Only the /// transport is supported (the 2020 WCF path is /// architecturally blocked — D2); other transports throw /// . The tag must already exist /// (create it with ). Value encoding is captured for Float tags. /// public Task AddHistoricalValuesAsync( string tag, IReadOnlyList values, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tag); ArgumentNullException.ThrowIfNull(values); if (_options.Transport != HistorianTransport.RemoteGrpc) { throw new ProtocolEvidenceMissingException( "AddHistoricalValuesAsync is only supported over the 2023 R2 RemoteGrpc transport; the 2020 WCF " + "non-streamed write is architecturally blocked (see docs/plans/revision-write-path.md, D2)."); } return new Grpc.HistorianGrpcHistoricalWriteOrchestrator(_options).AddHistoricalValuesAsync(tag, values, cancellationToken); } public IAsyncEnumerable BrowseTagNamesAsync(string filter = "*", CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(filter); return _options.Transport == HistorianTransport.RemoteGrpc ? Grpc.HistorianGrpcTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken) : HistorianWcfTagClient.BrowseTagNamesAsync(_options, filter, cancellationToken); } public Task GetTagMetadataAsync(string tag, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tag); return _options.Transport == HistorianTransport.RemoteGrpc ? Grpc.HistorianGrpcTagClient.GetTagMetadataAsync(_options, tag, cancellationToken) : HistorianWcfTagClient.GetTagMetadataAsync(_options, tag, cancellationToken); } public Task GetConnectionStatusAsync(CancellationToken cancellationToken = default) { return _protocol.GetConnectionStatusAsync(cancellationToken); } public Task GetStoreForwardStatusAsync(CancellationToken cancellationToken = default) { return _protocol.GetStoreForwardStatusAsync(cancellationToken); } public Task GetSystemParameterAsync(string name, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(name); 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 /// string value, or null when the server reports no value. Single string-valued parameters /// only (the evidence-backed surface); see HistorianRuntimeParameterProtocol. /// public Task GetRuntimeParameterAsync(string name, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(name); return _protocol.GetRuntimeParameterAsync(name, cancellationToken); } /// /// Reads the extended (user-defined) properties attached to a tag via the 2020 WCF /// GetTepByNm 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 /// HistorianTagExtendedPropertyProtocol. /// public Task> GetTagExtendedPropertiesAsync(string tag, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tag); return _protocol.GetTagExtendedPropertiesAsync(tag, cancellationToken); } /// /// Adds (or updates) extended (user-defined) properties on an existing tag via the 2020 WCF /// AddTEx (AddTagExtendedProperties) op. Requires a write-enabled connection. String-valued /// properties only (the evidence-backed surface). The new properties are read back via /// . See HistorianTagExtendedPropertyProtocol. /// public Task AddTagExtendedPropertiesAsync(string tag, IReadOnlyList properties, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tag); ArgumentNullException.ThrowIfNull(properties); return new HistorianWcfTagWriteOrchestrator(_options).AddTagExtendedPropertiesAsync(tag, properties, cancellationToken); } /// Convenience overload of for a single /// string-valued property. public Task AddTagExtendedPropertyAsync(string tag, string name, string value, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tag); ArgumentException.ThrowIfNullOrWhiteSpace(name); return AddTagExtendedPropertiesAsync(tag, [new HistorianTagExtendedProperty(name, value ?? string.Empty)], cancellationToken); } // Extended-property DELETE (DelTep) is intentionally NOT exposed publicly. Its wire format is // captured and the serializer (HistorianTagExtendedPropertyProtocol.SerializeDeleteRequest) is // golden-verified against a server-accepted buffer, but the SDK cannot yet make the 2020 server // accept the delete: the server's CHistStorage::DeleteTagExtendedProperties consults a // per-connection working set that the native client populates by multiplexing GetTepByNm and // DelTep over ONE connection, which the SDK's per-service WCF channels don't reproduce. See the // documented-but-blocked path in HistorianWcfTagWriteOrchestrator and // docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete. /// /// Executes a SQL command against the Historian over the WCF ExeC/GetR ops and /// returns the record set as a (the managed equivalent of the /// native DataTable). The record-set path (, /// the default) is the evidence-backed surface; the result is decoded from the server's /// NRBF-serialized DataTable without BinaryFormatter. See HistorianSqlResultProtocol. /// public Task ExecuteSqlCommandAsync( string command, HistorianSqlExecuteOption option = HistorianSqlExecuteOption.ExecuteRecord, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command); return _protocol.ExecuteSqlCommandAsync(command, option, cancellationToken); } /// /// Creates or updates the named tag in the Historian Runtime database via /// EnsureTags2. Currently only is /// live-verified. Note: writing data values to the new tag (via a separate /// AddStreamedValue/AddS2 path) is NOT supported by the SDK — see /// docs/plans/write-commands-reverse-engineering.md for the architectural /// finding. /// public Task EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(definition); return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken); } /// /// Deletes the named tag via DeleteTags. **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 Runtime.dbo.Tag). 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. /// public Task DeleteTagAsync(string tagName, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tagName); return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken); } /// /// Renames one tag, submitting an asynchronous rename job via the History StartJob (StJb) /// operation. Convenience wrapper over for a single (old,new) pair. /// Requires the server's AllowRenameTags system parameter to be enabled. /// public Task RenameTagAsync(string oldName, string newName, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(oldName); ArgumentException.ThrowIfNullOrWhiteSpace(newName); return RenameTagsAsync([(oldName, newName)], cancellationToken); } /// /// Renames a batch of tags. Each pair is (current name, new name). Rename is an asynchronous /// server-side job: the batch is submitted via the History StartJob (StJb) operation and /// the returned reports whether the server accepted/queued /// the job (and its job id); the renames apply in the background. The server's /// AllowRenameTags system parameter must be enabled or the server rejects the job. See /// docs/reverse-engineering/wcf-rename-tags.md. /// public Task RenameTagsAsync(IReadOnlyList<(string OldName, string NewName)> pairs, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(pairs); return new HistorianWcfTagWriteOrchestrator(_options).RenameTagsAsync(pairs, 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."); } } }