/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, copy, * modify, merge, publish, distribute, sublicense, and/or sell copies * of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Client.Historian; namespace Quickstarts.ConsoleReferenceClient { /// /// End-to-end Historical Access workflow for the ReferenceServer. /// public static class HistorianClientSample { /// /// Discovers historian capabilities and exercises data, annotations, /// aggregates, and event history through . /// /// public static async Task RunAsync( ISession session, CancellationToken cancellationToken = default) { int namespaceIndex = session.NamespaceUris.GetIndex(ReferenceServerNamespaceUri); if (namespaceIndex < 0) { throw new ServiceResultException( StatusCodes.BadNodeIdUnknown, $"The server does not expose {ReferenceServerNamespaceUri}."); } var dataNodeId = new NodeId(DataNodeName, (ushort)namespaceIndex); var structuredNodeId = new NodeId( StructuredNodeName, (ushort)namespaceIndex); var eventNotifierId = new NodeId(EventNotifierName, (ushort)namespaceIndex); HistoryClient client = session.Historian(new HistoryClientOptions { MaxPagesPerRead = 10_000, MaxReadDuration = TimeSpan.FromMinutes(1) }); HistoryServerCapabilitiesInfo capabilities = await client.GetServerCapabilitiesAsync(cancellationToken).ConfigureAwait(false); HistoricalConformanceInfo conformance = await client.GetConformanceInfoAsync( cancellationToken).ConfigureAwait(false); HistoricalDataConfigurationInfo configuration = await client.GetConfigurationAsync( dataNodeId, cancellationToken).ConfigureAwait(false); HistoricalDataConfigurationInfo structuredConfiguration = await client.GetConfigurationAsync( structuredNodeId, cancellationToken).ConfigureAwait(false); HistoricalEventConfigurationInfo eventConfiguration = await client.GetEventConfigurationAsync( eventNotifierId, cancellationToken).ConfigureAwait(false); ValidateDiscovery( capabilities, conformance, configuration, structuredConfiguration, eventConfiguration); Console.WriteLine( "Historian capabilities: data={0}, events={1}, data writes={2}, event writes={3}.", capabilities.AccessHistoryData, capabilities.AccessHistoryEvents, capabilities.InsertData && capabilities.ReplaceData && capabilities.UpdateData, capabilities.InsertEvent && capabilities.ReplaceEvent && capabilities.UpdateEvent && capabilities.DeleteEvent); Console.WriteLine( "Historical claims: server profiles={0}, conformance units={1}.", conformance.ServerProfiles.Count, conformance.ConformanceUnits.Count); Console.WriteLine( "Data configuration: installed={0}, server timestamps={1}, archive start={2:O}.", configuration.HasConfiguration, configuration.ServerTimestampSupported, configuration.StartOfArchive); Console.WriteLine( "Event configuration: installed={0}, event types={1}, archive start={2:O}.", eventConfiguration.HasConfiguration, eventConfiguration.EventTypes.Count, eventConfiguration.StartOfArchive); DateTime endTime = DateTime.UtcNow; DateTime startTime = endTime.AddHours(-4); int rawCount = await CountAsync( client.ReadRawAsync( dataNodeId, startTime, endTime, maxValuesPerNode: 50, returnBounds: true, timestampsToReturn: TimestampsToReturn.Both, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); int atTimeCount = await CountAsync( client.ReadAtTimeAsync( dataNodeId, [startTime.AddMinutes(30), startTime.AddHours(2)], useSimpleBounds: false, TimestampsToReturn.Both, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); Console.WriteLine("Raw history values={0}; at-time values={1}.", rawCount, atTimeCount); await ReadAggregateFamiliesAsync( client, dataNodeId, startTime, endTime, cancellationToken).ConfigureAwait(false); await DemonstrateDataUpdatesAsync( client, dataNodeId, cancellationToken).ConfigureAwait(false); await DemonstrateStructuredDataAsync( client, structuredNodeId, cancellationToken).ConfigureAwait(false); await DemonstrateEventsAsync( client, eventNotifierId, cancellationToken).ConfigureAwait(false); await DemonstrateAnnotationsAsync( client, dataNodeId, cancellationToken).ConfigureAwait(false); } private static async ValueTask ReadAggregateFamiliesAsync( HistoryClient client, NodeId nodeId, DateTime startTime, DateTime endTime, CancellationToken cancellationToken) { (string Name, NodeId Id)[] aggregateFamilies = [ ("interpolative", ObjectIds.AggregateFunction_Interpolative), ("average/total", ObjectIds.AggregateFunction_Average), ("minimum/maximum", ObjectIds.AggregateFunction_Minimum), ("count/state", ObjectIds.AggregateFunction_Count), ("start/end/delta", ObjectIds.AggregateFunction_Start), ("quality", ObjectIds.AggregateFunction_DurationGood), ("statistical", ObjectIds.AggregateFunction_StandardDeviationPopulation) ]; foreach ((string name, NodeId aggregateId) in aggregateFamilies) { int count = await CountAsync( client.ReadProcessedAsync( nodeId, aggregateId, startTime, endTime, processingInterval: TimeSpan.FromMinutes(30).TotalMilliseconds, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); Console.WriteLine("Aggregate family {0}: {1} processed values.", name, count); } } private static async ValueTask DemonstrateDataUpdatesAsync( HistoryClient client, NodeId nodeId, CancellationToken cancellationToken) { DateTime timestamp = DateTime.UtcNow.AddYears(-20); DataValue insert = CreateHistoricalValue(4387, timestamp); AssertGood( await client.InsertAsync( nodeId, [insert], cancellationToken).ConfigureAwait(false), "insert raw value"); DataValue replacement = CreateHistoricalValue(4388, timestamp); AssertGood( await client.ReplaceAsync( nodeId, [replacement], cancellationToken).ConfigureAwait(false), "replace raw value"); DataValue update = CreateHistoricalValue(4389, timestamp); AssertGood( await client.UpdateAsync( nodeId, [update], cancellationToken).ConfigureAwait(false), "update raw value"); int modifiedCount = await CountAsync( client.ReadModifiedAsync( nodeId, timestamp.AddMilliseconds(-1), timestamp.AddMilliseconds(1), maxValuesPerNode: 1, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); Console.WriteLine("Modified-history entries={0}.", modifiedCount); AssertGood( await client.DeleteAtTimeAsync( nodeId, [timestamp], cancellationToken).ConfigureAwait(false), "delete raw value"); DateTime rangeTimestamp = timestamp.AddSeconds(10); AssertGood( await client.InsertAsync( nodeId, [CreateHistoricalValue(4390, rangeTimestamp)], cancellationToken).ConfigureAwait(false), "insert value for range delete"); AssertGood( await client.DeleteRawAsync( nodeId, rangeTimestamp.AddMilliseconds(-1), rangeTimestamp.AddMilliseconds(1), cancellationToken: cancellationToken) .ConfigureAwait(false), "delete raw range"); } private static async ValueTask DemonstrateAnnotationsAsync( HistoryClient client, NodeId nodeId, CancellationToken cancellationToken) { DateTime annotationTime = DateTime.UtcNow.AddYears(-20).AddMinutes(1); ArrayOf annotations = [ new Annotation { AnnotationTime = annotationTime, Message = "ReferenceClient historian sample", UserName = "ReferenceClient" }, new Annotation { AnnotationTime = annotationTime.AddSeconds(1), Message = "ReferenceClient historian sample batch", UserName = "ReferenceClient" } ]; AssertGood( await client.WriteAnnotationsAsync( nodeId, annotations, cancellationToken: cancellationToken).ConfigureAwait(false), "insert annotation"); ArrayOf replacements = [ new Annotation { AnnotationTime = annotations[0].AnnotationTime, Message = "ReferenceClient replaced annotation", UserName = "ReferenceClient" }, new Annotation { AnnotationTime = annotations[1].AnnotationTime, Message = "ReferenceClient replaced annotation batch", UserName = "ReferenceClient" } ]; AssertGood( await client.WriteAnnotationsAsync( nodeId, replacements, PerformUpdateType.Replace, cancellationToken).ConfigureAwait(false), "replace annotations"); var updated = new Annotation { AnnotationTime = replacements[1].AnnotationTime, Message = "ReferenceClient updated annotation", UserName = "ReferenceClient" }; AssertGood( await client.WriteAnnotationsAsync( nodeId, [updated], PerformUpdateType.Update, cancellationToken).ConfigureAwait(false), "update annotation"); int annotationCount = await CountAsync( client.ReadAnnotationsAsync( nodeId, annotationTime.AddMilliseconds(-1), updated.AnnotationTime.ToDateTime().AddMilliseconds(1), cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); Console.WriteLine("Annotations read={0}.", annotationCount); AssertGood( await client.WriteAnnotationsAsync( nodeId, [replacements[0], updated], PerformUpdateType.Remove, cancellationToken).ConfigureAwait(false), "remove annotation"); } private static async ValueTask DemonstrateStructuredDataAsync( HistoryClient client, NodeId nodeId, CancellationToken cancellationToken) { DateTime timestamp = DateTime.UtcNow.AddYears(-20).AddMinutes(3); DataValue pressure = CreateStructuredValue( nodeId.NamespaceIndex, "Pressure", timestamp, 42.5); DataValue temperature = CreateStructuredValue( nodeId.NamespaceIndex, "Temperature", timestamp, 21.25); AssertGood( await client.UpdateStructureDataAsync( nodeId, PerformUpdateType.Insert, [pressure, temperature], cancellationToken).ConfigureAwait(false), "insert structured values"); // IDE0001 is suppressed: "KeyValuePair" is ambiguous between Opc.Ua.KeyValuePair // and System.Collections.Generic.KeyValuePair in this file (CS0104), so the // qualification cannot be simplified away. #pragma warning disable IDE0001 var readBack = new List(); #pragma warning restore IDE0001 await foreach (DataValue value in client.ReadRawAsync( nodeId, timestamp.AddMilliseconds(-1), timestamp.AddMilliseconds(1), maxValuesPerNode: 1, nodeOptions: new HistoryReadNodeOptions { DataEncoding = new QualifiedName( BrowseNames.DefaultBinary) }, cancellationToken: cancellationToken).ConfigureAwait(false)) { readBack.Add(ReadStructuredValue(value)); } if (readBack.Count != 2) { throw new ServiceResultException( StatusCodes.BadNoData, "The structured history read did not return both same-time keys."); } DataValue replacedPressure = CreateStructuredValue( nodeId.NamespaceIndex, "Pressure", timestamp, 43.0); AssertGood( await client.UpdateStructureDataAsync( nodeId, PerformUpdateType.Replace, [replacedPressure], cancellationToken).ConfigureAwait(false), "replace structured value"); DataValue updatedTemperature = CreateStructuredValue( nodeId.NamespaceIndex, "Temperature", timestamp, 22.0); AssertGood( await client.UpdateStructureDataAsync( nodeId, PerformUpdateType.Update, [updatedTemperature], cancellationToken).ConfigureAwait(false), "update structured value"); int modifiedCount = await CountAsync( client.ReadModifiedAsync( nodeId, timestamp.AddMilliseconds(-1), timestamp.AddMilliseconds(1), maxValuesPerNode: 1, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); int atTimeCount = await CountAsync( client.ReadAtTimeAsync( nodeId, [timestamp], nodeOptions: new HistoryReadNodeOptions { DataEncoding = new QualifiedName( BrowseNames.DefaultBinary) }, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); if (modifiedCount < 2 || atTimeCount != 1) { throw new ServiceResultException( StatusCodes.BadNoData, "Structured modified/at-time history was incomplete."); } AssertGood( await client.UpdateStructureDataAsync( nodeId, PerformUpdateType.Remove, [replacedPressure, updatedTemperature], cancellationToken).ConfigureAwait(false), "remove structured values"); Console.WriteLine( "Structured history CRUD completed; same-time keys={0}, modified={1}.", readBack.Count, modifiedCount); } private static async ValueTask DemonstrateEventsAsync( HistoryClient client, NodeId notifierId, CancellationToken cancellationToken) { EventFilter filter = CreateEventFilter(); await DemonstrateCapturedServerEventAsync( client, notifierId, filter, cancellationToken).ConfigureAwait(false); DateTime eventTime = DateTime.UtcNow.AddYears(-20).AddMinutes(2); HistoryEventFieldList inserted = CreateEvent( ByteString.Empty, notifierId, eventTime, "ReferenceClient inserted historical event"); AssertGood( await client.InsertEventsAsync( notifierId, filter, [inserted], cancellationToken).ConfigureAwait(false), "insert event"); ByteString generatedEventId = ByteString.Empty; await foreach (HistoryEventFieldList historicalEvent in client.ReadEventsAsync( notifierId, eventTime.AddMilliseconds(-1), eventTime.AddMilliseconds(1), filter, maxValuesPerNode: 1, cancellationToken: cancellationToken).ConfigureAwait(false)) { if (historicalEvent.EventFields.Count > 0 && historicalEvent.EventFields[0].TryGetValue(out ByteString eventId)) { generatedEventId = eventId; break; } } if (generatedEventId.IsEmpty) { throw new ServiceResultException( StatusCodes.BadNoData, "The server did not return the generated EventId."); } AssertGood( await client.ReplaceEventsAsync( notifierId, filter, [ CreateEvent( generatedEventId, notifierId, eventTime, "ReferenceClient replaced historical event") ], cancellationToken).ConfigureAwait(false), "replace event"); AssertGood( await client.UpdateEventsAsync( notifierId, filter, [ CreateEvent( generatedEventId, notifierId, eventTime, "ReferenceClient updated historical event") ], cancellationToken).ConfigureAwait(false), "update event"); AssertGood( await client.DeleteEventsAsync( notifierId, [generatedEventId], cancellationToken).ConfigureAwait(false), "delete event"); Console.WriteLine("Historical event CRUD completed; generated EventId={0}.", generatedEventId); } private static async ValueTask DemonstrateCapturedServerEventAsync( HistoryClient client, NodeId notifierId, EventFilter filter, CancellationToken cancellationToken) { DateTime startTime = DateTime.UtcNow.AddSeconds(-1); var triggerNodeId = new NodeId( EventTriggerNodeName, notifierId.NamespaceIndex); WriteResponse response = await client.Session.WriteAsync( null, [ new WriteValue { NodeId = triggerNodeId, AttributeId = Attributes.Value, Value = new DataValue(Variant.From(Environment.TickCount)) } ], cancellationToken).ConfigureAwait(false); if (response.Results.Count != 1 || StatusCode.IsBad(response.Results[0])) { StatusCode status = response.Results.Count == 1 ? response.Results[0] : StatusCodes.BadUnexpectedError; throw new ServiceResultException( status, "Writing the ReferenceServer event trigger failed."); } bool found = false; for (int attempt = 0; attempt < 20 && !found; attempt++) { await foreach (HistoryEventFieldList historicalEvent in client.ReadEventsAsync( notifierId, startTime, DateTime.UtcNow.AddSeconds(1), filter, cancellationToken: cancellationToken) .ConfigureAwait(false)) { if (historicalEvent.EventFields.Count > 4 && historicalEvent.EventFields[4].TryGetValue( out LocalizedText message) && message.Text?.Contains( "Trigger event", StringComparison.Ordinal) == true) { found = true; break; } } if (!found) { await Task.Delay( TimeSpan.FromMilliseconds(50), cancellationToken).ConfigureAwait(false); } } if (!found) { throw new ServiceResultException( StatusCodes.BadNoData, "The server-reported event was not captured in event history."); } Console.WriteLine("Server-reported event was captured automatically."); } private static EventFilter CreateEventFilter() { var filter = new EventFilter(); filter.AddSelectClause( ObjectTypeIds.BaseEventType, BrowseNames.EventId, Attributes.Value); filter.AddSelectClause( ObjectTypeIds.BaseEventType, BrowseNames.EventType, Attributes.Value); filter.AddSelectClause( ObjectTypeIds.BaseEventType, BrowseNames.SourceNode, Attributes.Value); filter.AddSelectClause( ObjectTypeIds.BaseEventType, BrowseNames.Time, Attributes.Value); filter.AddSelectClause( ObjectTypeIds.BaseEventType, BrowseNames.Message, Attributes.Value); return filter; } private static void ValidateDiscovery( HistoryServerCapabilitiesInfo capabilities, HistoricalConformanceInfo conformance, HistoricalDataConfigurationInfo dataConfiguration, HistoricalDataConfigurationInfo structuredConfiguration, HistoricalEventConfigurationInfo eventConfiguration) { if (!capabilities.AccessHistoryData || !capabilities.AccessHistoryEvents || !capabilities.InsertData || !capabilities.ReplaceData || !capabilities.UpdateData || !capabilities.DeleteRaw || !capabilities.DeleteAtTime || !capabilities.InsertAnnotation || !capabilities.InsertEvent || !capabilities.ReplaceEvent || !capabilities.UpdateEvent || !capabilities.DeleteEvent || !capabilities.ServerTimestampSupported) { throw new ServiceResultException( StatusCodes.BadConfigurationError, "The ReferenceServer does not expose the complete Historical Access capability set."); } if (!dataConfiguration.HasConfiguration || !structuredConfiguration.HasConfiguration || !eventConfiguration.HasConfiguration) { throw new ServiceResultException( StatusCodes.BadConfigurationError, "The ReferenceServer is missing a historical configuration object."); } ArrayOf serverProfiles = HistoricalAccessProfileCatalog.GetProfiles( HistoricalAccessProfileSide.Server); if (conformance.ServerProfiles.Count != serverProfiles.Count) { throw new ServiceResultException( StatusCodes.BadConfigurationError, "The ReferenceServer does not advertise all 15 Historical Access Server facets."); } for (int i = 0; i < serverProfiles.Count; i++) { HistoricalAccessProfileDescriptor profile = serverProfiles[i]; if (!conformance.ServerProfiles.Contains( profile.ProfileUri)) { throw new ServiceResultException( StatusCodes.BadConfigurationError, $"The ReferenceServer is missing profile {profile.ProfileUri}."); } foreach (string unitName in profile.MandatoryConformanceUnits) { if (!conformance.ConformanceUnits.Contains( new QualifiedName(unitName))) { throw new ServiceResultException( StatusCodes.BadConfigurationError, $"The ReferenceServer is missing conformance unit {unitName}."); } } } foreach (HistoricalAggregateFunctionDescriptor aggregate in HistoricalAggregateFunctionCatalog.AllFunctions) { if (!conformance.ConformanceUnits.Contains( new QualifiedName( aggregate.ServerConformanceUnit))) { throw new ServiceResultException( StatusCodes.BadConfigurationError, $"The ReferenceServer is missing aggregate unit {aggregate.ServerConformanceUnit}."); } } } private static HistoryEventFieldList CreateEvent( ByteString eventId, NodeId sourceNode, DateTime eventTime, string message) { return new HistoryEventFieldList { EventFields = [ new Variant(eventId), new Variant(ObjectTypeIds.BaseEventType), new Variant(sourceNode), new Variant((DateTimeUtc)eventTime), new Variant(new LocalizedText(message)) ] }; } private static DataValue CreateHistoricalValue(int value, DateTime timestamp) { return new DataValue( Variant.From(value), StatusCodes.Good, sourceTimestamp: timestamp, serverTimestamp: timestamp); } private static DataValue CreateStructuredValue( ushort namespaceIndex, string key, DateTime timestamp, double value) { // IDE0001 is suppressed: "KeyValuePair" is ambiguous between Opc.Ua.KeyValuePair // and System.Collections.Generic.KeyValuePair in this file (CS0104), so the // qualification cannot be simplified away. #pragma warning disable IDE0001 var pair = new Opc.Ua.KeyValuePair { Key = new QualifiedName(key, namespaceIndex), Value = Variant.From(value) }; #pragma warning restore IDE0001 return new DataValue( new Variant(new ExtensionObject(pair)), StatusCodes.Good, timestamp, timestamp); } // IDE0001 is suppressed: "KeyValuePair" is ambiguous between Opc.Ua.KeyValuePair // and System.Collections.Generic.KeyValuePair in this file (CS0104), so the // qualification cannot be simplified away. #pragma warning disable IDE0001 private static Opc.Ua.KeyValuePair ReadStructuredValue( DataValue value) { if (value.WrappedValue.TryGetValue( out ExtensionObject extension) && extension.TryGetValue( out Opc.Ua.KeyValuePair? pair) && pair != null) { return pair; } #pragma warning restore IDE0001 throw new ServiceResultException( StatusCodes.BadDecodingError, "Structured history did not contain a KeyValuePair."); } private static async ValueTask CountAsync( IAsyncEnumerable values, CancellationToken cancellationToken) { int count = 0; await foreach (T _ in values.WithCancellation(cancellationToken).ConfigureAwait(false)) { count++; } return count; } private static void AssertGood( ArrayOf statuses, string operation) { for (int i = 0; i < statuses.Count; i++) { if (StatusCode.IsBad(statuses[i])) { throw new ServiceResultException( statuses[i], $"Historical Access sample failed to {operation}."); } } } private static void AssertGood( StatusCode status, string operation) { if (StatusCode.IsBad(status)) { throw new ServiceResultException( status, $"Historical Access sample failed to {operation}."); } } private const string ReferenceServerNamespaceUri = "http://opcfoundation.org/Quickstarts/ReferenceServer"; private const string DataNodeName = "Scalar_Static_Int32"; private const string StructuredNodeName = "Historical_KeyValuePairs"; private const string EventNotifierName = "CTT"; private const string EventTriggerNodeName = "NodeIds_Events_TriggerNode01"; } }