Migrate historian from SQL to aahClientManaged SDK and resolve all OPC UA Part 11 gaps
Replace direct SQL queries against Historian Runtime database with the Wonderware Historian managed SDK (ArchestrA.HistorianAccess). Add HistoryServerCapabilities node, AggregateFunctions folder, continuation points, ReadAtTime interpolation, ReturnBounds, ReadModified rejection, HistoricalDataConfiguration per node, historical event access, and client-side StandardDeviation aggregate support. Remove screenshot tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
private readonly bool _anonymousCanWrite;
|
||||
private readonly AutoResetEvent _dataChangeSignal = new(false);
|
||||
private readonly Dictionary<int, List<string>> _gobjectToTagRefs = new();
|
||||
private readonly HistoryContinuationPointManager _historyContinuations = new();
|
||||
private readonly HistorianDataSource? _historianDataSource;
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
|
||||
@@ -896,6 +897,44 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
variable.AccessLevel = accessLevel;
|
||||
variable.UserAccessLevel = accessLevel;
|
||||
variable.Historizing = attr.IsHistorized;
|
||||
|
||||
if (attr.IsHistorized)
|
||||
{
|
||||
var histConfigNodeId = new NodeId(nodeIdString + ".HAConfiguration", NamespaceIndex);
|
||||
var histConfig = new BaseObjectState(variable)
|
||||
{
|
||||
NodeId = histConfigNodeId,
|
||||
BrowseName = new QualifiedName("HAConfiguration", NamespaceIndex),
|
||||
DisplayName = "HA Configuration",
|
||||
TypeDefinitionId = ObjectTypeIds.HistoricalDataConfigurationType
|
||||
};
|
||||
|
||||
var steppedProp = new PropertyState<bool>(histConfig)
|
||||
{
|
||||
NodeId = new NodeId(nodeIdString + ".HAConfiguration.Stepped", NamespaceIndex),
|
||||
BrowseName = BrowseNames.Stepped,
|
||||
DisplayName = "Stepped",
|
||||
Value = false,
|
||||
AccessLevel = AccessLevels.CurrentRead,
|
||||
UserAccessLevel = AccessLevels.CurrentRead
|
||||
};
|
||||
histConfig.AddChild(steppedProp);
|
||||
|
||||
var definitionProp = new PropertyState<string>(histConfig)
|
||||
{
|
||||
NodeId = new NodeId(nodeIdString + ".HAConfiguration.Definition", NamespaceIndex),
|
||||
BrowseName = BrowseNames.Definition,
|
||||
DisplayName = "Definition",
|
||||
Value = "Wonderware Historian",
|
||||
AccessLevel = AccessLevels.CurrentRead,
|
||||
UserAccessLevel = AccessLevels.CurrentRead
|
||||
};
|
||||
histConfig.AddChild(definitionProp);
|
||||
|
||||
variable.AddChild(histConfig);
|
||||
AddPredefinedNode(SystemContext, histConfig);
|
||||
}
|
||||
|
||||
variable.Value = NormalizePublishedValue(attr.FullTagReference, null);
|
||||
variable.StatusCode = StatusCodes.BadWaitingForInitialData;
|
||||
variable.Timestamp = DateTime.UtcNow;
|
||||
@@ -1390,6 +1429,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
var idx = handle.Index;
|
||||
|
||||
// Handle continuation point resumption
|
||||
if (nodesToRead[idx].ContinuationPoint != null && nodesToRead[idx].ContinuationPoint.Length > 0)
|
||||
{
|
||||
var remaining = _historyContinuations.Retrieve(nodesToRead[idx].ContinuationPoint);
|
||||
if (remaining == null)
|
||||
{
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadContinuationPointInvalid);
|
||||
continue;
|
||||
}
|
||||
|
||||
ReturnHistoryPage(remaining, details.NumValuesPerNode, results, errors, idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeIdStr = handle.NodeId?.Identifier as string;
|
||||
if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
@@ -1403,6 +1457,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
continue;
|
||||
}
|
||||
|
||||
if (details.IsReadModified)
|
||||
{
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var maxValues = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0;
|
||||
@@ -1410,15 +1470,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
tagRef, details.StartTime, details.EndTime, maxValues)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var historyData = new HistoryData();
|
||||
historyData.DataValues.AddRange(dataValues);
|
||||
if (details.ReturnBounds)
|
||||
AddBoundingValues(dataValues, details.StartTime, details.EndTime);
|
||||
|
||||
results[idx] = new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good,
|
||||
HistoryData = new ExtensionObject(historyData)
|
||||
};
|
||||
errors[idx] = ServiceResult.Good;
|
||||
ReturnHistoryPage(dataValues, details.NumValuesPerNode, results, errors, idx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1442,6 +1497,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
var idx = handle.Index;
|
||||
|
||||
// Handle continuation point resumption
|
||||
if (nodesToRead[idx].ContinuationPoint != null && nodesToRead[idx].ContinuationPoint.Length > 0)
|
||||
{
|
||||
var remaining = _historyContinuations.Retrieve(nodesToRead[idx].ContinuationPoint);
|
||||
if (remaining == null)
|
||||
{
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadContinuationPointInvalid);
|
||||
continue;
|
||||
}
|
||||
|
||||
ReturnHistoryPage(remaining, 0, results, errors, idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeIdStr = handle.NodeId?.Identifier as string;
|
||||
if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
@@ -1476,6 +1546,58 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
details.ProcessingInterval, column)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
ReturnHistoryPage(dataValues, 0, results, errors, idx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead processed failed for {TagRef}", tagRef);
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void HistoryReadAtTime(
|
||||
ServerSystemContext context,
|
||||
ReadAtTimeDetails details,
|
||||
TimestampsToReturn timestampsToReturn,
|
||||
IList<HistoryReadValueId> nodesToRead,
|
||||
IList<HistoryReadResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
var idx = handle.Index;
|
||||
var nodeIdStr = handle.NodeId?.Identifier as string;
|
||||
if (nodeIdStr == null || !_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_historianDataSource == null)
|
||||
{
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (details.ReqTimes == null || details.ReqTimes.Count == 0)
|
||||
{
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadInvalidArgument);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var timestamps = new DateTime[details.ReqTimes.Count];
|
||||
for (var i = 0; i < details.ReqTimes.Count; i++)
|
||||
timestamps[i] = details.ReqTimes[i];
|
||||
|
||||
var dataValues = _historianDataSource.ReadAtTimeAsync(tagRef, timestamps)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var historyData = new HistoryData();
|
||||
historyData.DataValues.AddRange(dataValues);
|
||||
|
||||
@@ -1488,12 +1610,149 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead processed failed for {TagRef}", tagRef);
|
||||
Log.Warning(ex, "HistoryRead at-time failed for {TagRef}", tagRef);
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void HistoryReadEvents(
|
||||
ServerSystemContext context,
|
||||
ReadEventDetails details,
|
||||
TimestampsToReturn timestampsToReturn,
|
||||
IList<HistoryReadValueId> nodesToRead,
|
||||
IList<HistoryReadResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
var idx = handle.Index;
|
||||
var nodeIdStr = handle.NodeId?.Identifier as string;
|
||||
|
||||
if (_historianDataSource == null)
|
||||
{
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadHistoryOperationUnsupported);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the source name for event filtering.
|
||||
// Alarm condition nodes end with ".Condition" — strip to get the source tag.
|
||||
// Area/object nodes filter by Source_Name matching the browse name.
|
||||
string? sourceName = null;
|
||||
if (nodeIdStr != null)
|
||||
{
|
||||
if (nodeIdStr.EndsWith(".Condition"))
|
||||
{
|
||||
var baseTag = nodeIdStr.Substring(0, nodeIdStr.Length - ".Condition".Length);
|
||||
sourceName = baseTag;
|
||||
}
|
||||
else if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
|
||||
{
|
||||
sourceName = tagRef;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var maxEvents = details.NumValuesPerNode > 0 ? (int)details.NumValuesPerNode : 0;
|
||||
var events = _historianDataSource.ReadEventsAsync(
|
||||
sourceName, details.StartTime, details.EndTime, maxEvents)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var historyEvent = new HistoryEvent();
|
||||
foreach (var evt in events)
|
||||
{
|
||||
// Build the standard event field list per OPC UA Part 11
|
||||
// Fields: EventId, EventType, SourceNode, SourceName, Time, ReceiveTime,
|
||||
// Message, Severity
|
||||
var fields = new HistoryEventFieldList();
|
||||
fields.EventFields.Add(new Variant(evt.Id.ToByteArray()));
|
||||
fields.EventFields.Add(new Variant(ObjectTypeIds.AlarmConditionType));
|
||||
fields.EventFields.Add(new Variant(
|
||||
nodeIdStr != null ? new NodeId(nodeIdStr, NamespaceIndex) : NodeId.Null));
|
||||
fields.EventFields.Add(new Variant(evt.Source ?? ""));
|
||||
fields.EventFields.Add(new Variant(
|
||||
DateTime.SpecifyKind(evt.EventTime, DateTimeKind.Utc)));
|
||||
fields.EventFields.Add(new Variant(
|
||||
DateTime.SpecifyKind(evt.ReceivedTime, DateTimeKind.Utc)));
|
||||
fields.EventFields.Add(new Variant(new LocalizedText(evt.DisplayText ?? "")));
|
||||
fields.EventFields.Add(new Variant((ushort)evt.Severity));
|
||||
historyEvent.Events.Add(fields);
|
||||
}
|
||||
|
||||
results[idx] = new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good,
|
||||
HistoryData = new ExtensionObject(historyEvent)
|
||||
};
|
||||
errors[idx] = ServiceResult.Good;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead events failed for {NodeId}", nodeIdStr);
|
||||
errors[idx] = new ServiceResult(StatusCodes.BadInternalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReturnHistoryPage(List<DataValue> dataValues, uint numValuesPerNode,
|
||||
IList<HistoryReadResult> results, IList<ServiceResult> errors, int idx)
|
||||
{
|
||||
var pageSize = numValuesPerNode > 0 ? (int)numValuesPerNode : dataValues.Count;
|
||||
|
||||
var historyData = new HistoryData();
|
||||
byte[]? continuationPoint = null;
|
||||
|
||||
if (dataValues.Count > pageSize)
|
||||
{
|
||||
historyData.DataValues.AddRange(dataValues.GetRange(0, pageSize));
|
||||
var remainder = dataValues.GetRange(pageSize, dataValues.Count - pageSize);
|
||||
continuationPoint = _historyContinuations.Store(remainder);
|
||||
}
|
||||
else
|
||||
{
|
||||
historyData.DataValues.AddRange(dataValues);
|
||||
}
|
||||
|
||||
results[idx] = new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good,
|
||||
HistoryData = new ExtensionObject(historyData),
|
||||
ContinuationPoint = continuationPoint
|
||||
};
|
||||
errors[idx] = ServiceResult.Good;
|
||||
}
|
||||
|
||||
private static void AddBoundingValues(List<DataValue> dataValues, DateTime startTime, DateTime endTime)
|
||||
{
|
||||
// Insert start bound if first sample doesn't match start time
|
||||
if (dataValues.Count == 0 || dataValues[0].SourceTimestamp != startTime)
|
||||
{
|
||||
dataValues.Insert(0, new DataValue
|
||||
{
|
||||
Value = Variant.Null,
|
||||
SourceTimestamp = startTime,
|
||||
ServerTimestamp = startTime,
|
||||
StatusCode = StatusCodes.BadBoundNotFound
|
||||
});
|
||||
}
|
||||
|
||||
// Append end bound if last sample doesn't match end time
|
||||
if (dataValues.Count == 0 || dataValues[dataValues.Count - 1].SourceTimestamp != endTime)
|
||||
{
|
||||
dataValues.Add(new DataValue
|
||||
{
|
||||
Value = Variant.Null,
|
||||
SourceTimestamp = endTime,
|
||||
ServerTimestamp = endTime,
|
||||
StatusCode = StatusCodes.BadBoundNotFound
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Subscription Delivery
|
||||
|
||||
Reference in New Issue
Block a user