fix: preserve history sentinels and alarm SDK metadata

This commit is contained in:
Joseph Doherty
2026-11-10 10:20:53 -05:00
parent 33c7dc4292
commit 3dcb035f89
4 changed files with 23 additions and 12 deletions
@@ -30,8 +30,8 @@ internal static class AdvertisedAggregateFunctions
private static readonly object SyncGate = new();
/// <summary>Every standard aggregate-function NodeId the SDK's nodeset knows (<c>ObjectIds.AggregateFunction_*</c>).</summary>
private static readonly NodeId[] StandardAggregateIds = typeof(ObjectIds)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
.GetProperties(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.PropertyType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
.Select(f => (NodeId)f.GetValue(null)!)
.ToArray();
@@ -1522,6 +1522,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var parent = ResolveParentFolder(equipmentNodeId, realm);
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
// SDK 2.0 creates optional confirmation nodes only when requested explicitly.
// Both are part of the application's existing Part 9 method/state contract.
alarm.ConfirmedState = new TwoStateVariableState(alarm);
alarm.Confirm = new AddCommentMethodState(alarm);
alarm.SymbolicName = displayName;
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
@@ -5582,14 +5586,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// same HistoryRead authorization as a raw read (above); a backend with no trail ⇒ unsupported.
work.Add(() => ServeModifiedPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime.ToDateTime(), details.EndTime.ToDateTime(), details.NumValuesPerNode,
SdkValueBoundary.ToClrTimestamp(details.StartTime), SdkValueBoundary.ToClrTimestamp(details.EndTime), details.NumValuesPerNode,
ct));
continue;
}
work.Add(() => ServeRawPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime.ToDateTime(), details.EndTime.ToDateTime(), details.NumValuesPerNode,
SdkValueBoundary.ToClrTimestamp(details.StartTime), SdkValueBoundary.ToClrTimestamp(details.EndTime), details.NumValuesPerNode,
ct, details.ReturnBounds && HonorReturnBounds));
}
@@ -5649,8 +5653,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var agg = aggregate.Value;
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
tagname,
details.StartTime.ToDateTime(),
details.EndTime.ToDateTime(),
SdkValueBoundary.ToClrTimestamp(details.StartTime),
SdkValueBoundary.ToClrTimestamp(details.EndTime),
interval,
agg,
token), ct));
@@ -5827,8 +5831,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var source = _historianDataSource;
try
{
var startUtc = details.StartTime.ToDateTime();
var endUtc = details.EndTime.ToDateTime();
var startUtc = SdkValueBoundary.ToClrTimestamp(details.StartTime);
var endUtc = SdkValueBoundary.ToClrTimestamp(details.EndTime);
var numValuesPerNode = details.NumValuesPerNode;
var boundarySkip = 0;
var walkHint = default(ReverseWalkHint); // a walked-back read's width + proven-empty stretch
@@ -6167,8 +6171,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Deleting the modified-value trail itself (Part 11 IsDeleteModified) is not supported: the trail is the
// audit record of every change and is aged out only by retention.
if (details.IsDeleteModified) return null;
var start = details.StartTime.ToDateTime();
var end = details.EndTime.ToDateTime();
var start = SdkValueBoundary.ToClrTimestamp(details.StartTime);
var end = SdkValueBoundary.ToClrTimestamp(details.EndTime);
// Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse
// it here rather than let a backend read MinValue as "from the beginning of time".
if (start == DateTime.MinValue || end == DateTime.MinValue)
@@ -6531,7 +6535,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (string.IsNullOrWhiteSpace(typeName)) return ObjectTypeIds.AlarmConditionType;
return s_eventTypesByName.GetOrAdd(typeName, name =>
{
var field = typeof(ObjectTypeIds).GetField(name.EndsWith("Type", StringComparison.Ordinal) ? name : name + "Type",
var field = typeof(ObjectTypeIds).GetProperty(name.EndsWith("Type", StringComparison.Ordinal) ? name : name + "Type",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
return field?.GetValue(null) is NodeId type && Server.TypeTree.IsTypeOf(type, ObjectTypeIds.BaseEventType)
? type
@@ -6838,7 +6842,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// synthesised time-based:
/// <list type="bullet">
/// <item><b>Fresh read</b> (no inbound continuation point): read the window from
/// <c>details.StartTime.ToDateTime()</c> to <paramref name="endUtc"/> capped at
/// <c>SdkValueBoundary.ToClrTimestamp(details.StartTime)</c> to <paramref name="endUtc"/> capped at
/// <paramref name="numValuesPerNode"/> <b>+ 1</b> — one lookahead row. If the lookahead row came
/// back, return the first N, store a resume cursor and emit a continuation point; otherwise the page is
/// the last and carries none (so a total that is an exact multiple of N ends on a full page, not on an
@@ -21,6 +21,10 @@ internal static class SdkValueBoundary
throw new ArgumentException("Unsupported OPC UA value type.", nameof(value));
}
/// <summary>Map the UA zero timestamp to the application's existing unspecified-bound sentinel.</summary>
internal static DateTime ToClrTimestamp(DateTimeUtc value) => value == DateTimeUtc.MinValue
? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc) : value.ToDateTime();
/// <summary>Restore existing CLR payload types and detach mutable arrays.</summary>
internal static object? ToClr(Variant value) => value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
{
@@ -23,6 +23,9 @@
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests"/>
<!-- R2-08 (03/S2): the outage-sim integration test drives the internal static
OpcUaApplicationHost.HandleImpersonation against the real LDAP library. -->
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Host.IntegrationTests"/>