Refine XML docs for historian, OPC UA, and tests

This commit is contained in:
Joseph Doherty
2026-03-26 15:33:14 -04:00
parent 3c326e2d45
commit ce0b291664
14 changed files with 215 additions and 3 deletions

View File

@@ -18,6 +18,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
private readonly HistorianConfiguration _config;
/// <summary>
/// Initializes a Historian reader that translates OPC UA history requests into Wonderware Historian queries.
/// </summary>
/// <param name="config">The Historian connection settings and command timeout used for runtime history lookups.</param>
public HistorianDataSource(HistorianConfiguration config)
{
_config = config;
@@ -26,6 +30,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
/// <summary>
/// Reads raw historical values for a tag from the Historian.
/// </summary>
/// <param name="tagName">The Wonderware tag name backing the OPC UA node whose raw history is being requested.</param>
/// <param name="startTime">The inclusive start of the client-requested history window.</param>
/// <param name="endTime">The inclusive end of the client-requested history window.</param>
/// <param name="maxValues">The maximum number of samples to return when the OPC UA client limits the result set.</param>
/// <param name="ct">The cancellation token that aborts the database call when the OPC UA request is cancelled.</param>
public async Task<List<DataValue>> ReadRawAsync(
string tagName, DateTime startTime, DateTime endTime, int maxValues,
CancellationToken ct = default)
@@ -76,6 +85,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
/// <summary>
/// Reads aggregate historical values for a tag from the Historian.
/// </summary>
/// <param name="tagName">The Wonderware tag name backing the OPC UA node whose aggregate history is being requested.</param>
/// <param name="startTime">The inclusive start of the aggregate history window requested by the OPC UA client.</param>
/// <param name="endTime">The inclusive end of the aggregate history window requested by the OPC UA client.</param>
/// <param name="intervalMs">The Wonderware summary resolution, in milliseconds, used to bucket aggregate values.</param>
/// <param name="aggregateColumn">The Historian summary column that matches the OPC UA aggregate function being requested.</param>
/// <param name="ct">The cancellation token that aborts the aggregate query when the client request is cancelled.</param>
public async Task<List<DataValue>> ReadAggregateAsync(
string tagName, DateTime startTime, DateTime endTime,
double intervalMs, string aggregateColumn,
@@ -119,6 +134,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
/// <summary>
/// Maps Wonderware Historian quality codes to OPC UA StatusCodes.
/// </summary>
/// <param name="quality">The raw Wonderware Historian quality byte stored with a historical sample.</param>
public static StatusCode MapQuality(byte quality)
{
if (quality == 0)
@@ -134,6 +150,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
/// Maps an OPC UA aggregate NodeId to the corresponding Historian column name.
/// Returns null if the aggregate is not supported.
/// </summary>
/// <param name="aggregateId">The OPC UA aggregate identifier requested by the history client.</param>
public static string? MapAggregateToColumn(NodeId aggregateId)
{
if (aggregateId == ObjectIds.AggregateFunction_Average)

View File

@@ -12,6 +12,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <summary>
/// Compares old and new hierarchy+attributes and returns the set of gobject IDs that have any difference.
/// </summary>
/// <param name="oldHierarchy">The previously published Galaxy object hierarchy snapshot.</param>
/// <param name="oldAttributes">The previously published Galaxy attribute snapshot keyed to the old hierarchy.</param>
/// <param name="newHierarchy">The latest Galaxy object hierarchy snapshot pulled from the repository.</param>
/// <param name="newAttributes">The latest Galaxy attribute snapshot that should be reflected in the OPC UA namespace.</param>
public static HashSet<int> FindChangedGobjectIds(
List<GalaxyObjectInfo> oldHierarchy, List<GalaxyAttributeInfo> oldAttributes,
List<GalaxyObjectInfo> newHierarchy, List<GalaxyAttributeInfo> newAttributes)
@@ -70,6 +74,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <summary>
/// Expands a set of changed gobject IDs to include all descendant gobject IDs in the hierarchy.
/// </summary>
/// <param name="changed">The root Galaxy objects that were detected as changed between snapshots.</param>
/// <param name="hierarchy">The hierarchy used to include descendant objects whose OPC UA nodes must also be rebuilt.</param>
public static HashSet<int> ExpandToSubtrees(HashSet<int> changed, List<GalaxyObjectInfo> hierarchy)
{
var childrenByParent = hierarchy.GroupBy(h => h.ParentGobjectId)

View File

@@ -54,8 +54,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private sealed class TagMetadata
{
/// <summary>
/// Gets or sets the MXAccess data type code used to map Galaxy values into OPC UA variants.
/// </summary>
public int MxDataType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the source Galaxy attribute should be exposed as an array node.
/// </summary>
public bool IsArray { get; set; }
/// <summary>
/// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array.
/// </summary>
public int? ArrayDimension { get; set; }
}
@@ -70,14 +81,49 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private sealed class AlarmInfo
{
/// <summary>
/// Gets or sets the full tag reference for the process value whose alarm state is tracked.
/// </summary>
public string SourceTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the OPC UA node identifier for the source variable that owns the alarm condition.
/// </summary>
public NodeId SourceNodeId { get; set; } = NodeId.Null;
/// <summary>
/// Gets or sets the operator-facing source name used in generated alarm events.
/// </summary>
public string SourceName { get; set; } = "";
/// <summary>
/// Gets or sets the most recent in-alarm state so duplicate transitions are not reissued.
/// </summary>
public bool LastInAlarm { get; set; }
/// <summary>
/// Gets or sets the retained OPC UA condition node associated with the source alarm.
/// </summary>
public AlarmConditionState? ConditionNode { get; set; }
/// <summary>
/// Gets or sets the Galaxy tag reference that supplies runtime alarm priority updates.
/// </summary>
public string PriorityTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the Galaxy tag reference or attribute binding used to resolve the alarm message text.
/// </summary>
public string DescAttrNameTagReference { get; set; } = "";
/// <summary>
/// Gets or sets the cached OPC UA severity derived from the latest alarm priority value.
/// </summary>
public ushort CachedSeverity { get; set; }
/// <summary>
/// Gets or sets the cached alarm message used when emitting active and cleared events.
/// </summary>
public string CachedMessage { get; set; } = "";
}
@@ -124,6 +170,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <param name="namespaceUri">The namespace URI that identifies the Galaxy model to clients.</param>
/// <param name="mxAccessClient">The runtime client used to service reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector used to track node manager activity.</param>
/// <param name="historianDataSource">The optional historian adapter used to satisfy OPC UA history read requests.</param>
/// <param name="alarmTrackingEnabled">Enables alarm-condition state generation for Galaxy attributes modeled as alarms.</param>
public LmxNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
@@ -444,6 +492,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <summary>
/// Incrementally syncs the address space by detecting changed gobjects and rebuilding only those subtrees. (OPC-010)
/// </summary>
/// <param name="hierarchy">The latest Galaxy object hierarchy snapshot to compare against the currently published model.</param>
/// <param name="attributes">The latest Galaxy attribute snapshot to compare against the currently published variables.</param>
public void SyncAddressSpace(List<GalaxyObjectInfo> hierarchy, List<GalaxyAttributeInfo> attributes)
{
lock (Lock)
@@ -1140,9 +1190,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
#region Condition Refresh
/// <summary>
/// Reports all active retained alarm conditions during a condition refresh.
/// </summary>
/// <inheritdoc />
/// <param name="context">The OPC UA request context for the condition refresh operation.</param>
/// <param name="monitoredItems">The monitored event items that should receive retained alarm conditions.</param>
public override ServiceResult ConditionRefresh(OperationContext context, IList<IEventMonitoredItem> monitoredItems)
{
foreach (var kvp in _alarmInAlarmTags)

View File

@@ -42,6 +42,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <param name="galaxyName">The Galaxy name used to construct the namespace URI and product URI.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live data access.</param>
/// <param name="metrics">The metrics collector shared with the node manager.</param>
/// <param name="historianDataSource">The optional historian adapter used when clients issue OPC UA history reads.</param>
/// <param name="alarmTrackingEnabled">Enables alarm condition tracking for alarm-capable Galaxy attributes.</param>
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false)
{

View File

@@ -46,6 +46,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <param name="config">The endpoint and session settings for the OPC UA host.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live reads, writes, and subscriptions.</param>
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null)
{

View File

@@ -40,6 +40,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Status
/// <param name="metrics">The performance metrics collector whose operation statistics should be reported.</param>
/// <param name="galaxyStats">The Galaxy repository statistics to surface on the dashboard.</param>
/// <param name="serverHost">The OPC UA server host whose active session count should be reported.</param>
/// <param name="nodeManager">The node manager whose queue depth and MXAccess event throughput should be surfaced on the dashboard.</param>
public void SetComponents(IMxAccessClient? mxAccessClient, PerformanceMetrics? metrics,
GalaxyRepositoryStats? galaxyStats, OpcUaServerHost? serverHost,
LmxNodeManager? nodeManager = null)

View File

@@ -6,6 +6,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
{
public class SecurityClassificationMapperTests
{
/// <summary>
/// Verifies that Galaxy classifications intended for operator and engineering writes remain writable through OPC UA.
/// </summary>
/// <param name="classification">The Galaxy security classification value being evaluated for write access.</param>
/// <param name="expected">The expected writable result for the supplied Galaxy classification.</param>
[Theory]
[InlineData(0, true)] // FreeAccess
[InlineData(1, true)] // Operate
@@ -16,6 +21,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected);
}
/// <summary>
/// Verifies that secured or view-only Galaxy classifications are exposed as read-only attributes.
/// </summary>
/// <param name="classification">The Galaxy security classification value expected to block writes.</param>
/// <param name="expected">The expected writable result for the supplied read-only Galaxy classification.</param>
[Theory]
[InlineData(2, false)] // SecuredWrite
[InlineData(3, false)] // VerifiedWrite
@@ -25,6 +35,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected);
}
/// <summary>
/// Verifies that unknown security classifications do not accidentally block writes for unmapped Galaxy values.
/// </summary>
/// <param name="classification">An unmapped Galaxy security classification value that should fall back to writable behavior.</param>
[Theory]
[InlineData(-1)]
[InlineData(7)]

View File

@@ -7,18 +7,28 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
{
public class HistorianQualityMappingTests
{
/// <summary>
/// Verifies that the Historian good-quality sentinel is surfaced to OPC UA clients as a good status code.
/// </summary>
[Fact]
public void Quality0_MapsToGood()
{
HistorianDataSource.MapQuality(0).ShouldBe(StatusCodes.Good);
}
/// <summary>
/// Verifies that the Historian bad-quality sentinel is surfaced to OPC UA clients as a bad status code.
/// </summary>
[Fact]
public void Quality1_MapsToBad()
{
HistorianDataSource.MapQuality(1).ShouldBe(StatusCodes.Bad);
}
/// <summary>
/// Verifies that Historian uncertainty quality bands are translated into OPC UA uncertain results.
/// </summary>
/// <param name="quality">A Wonderware Historian quality byte in the uncertain range.</param>
[Theory]
[InlineData(128)]
[InlineData(133)]
@@ -28,6 +38,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
HistorianDataSource.MapQuality(quality).ShouldBe(StatusCodes.Uncertain);
}
/// <summary>
/// Verifies that nonzero non-uncertain Historian quality values are treated as bad historical samples.
/// </summary>
/// <param name="quality">A Wonderware Historian quality byte that should map to an OPC UA bad status.</param>
[Theory]
[InlineData(2)]
[InlineData(50)]

View File

@@ -31,6 +31,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
};
}
/// <summary>
/// Verifies that writable Galaxy security classifications publish OPC UA variables with read-write access.
/// </summary>
[Fact]
public async Task ReadWriteAttribute_HasCurrentReadOrWrite_AccessLevel()
{
@@ -52,6 +55,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
finally { await fixture.DisposeAsync(); }
}
/// <summary>
/// Verifies that secured and view-only Galaxy classifications publish OPC UA variables with read-only access.
/// </summary>
[Fact]
public async Task ReadOnlyAttribute_HasCurrentRead_AccessLevel()
{
@@ -73,6 +79,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
finally { await fixture.DisposeAsync(); }
}
/// <summary>
/// Verifies that the bridge rejects writes against Galaxy attributes whose security classification is read-only.
/// </summary>
[Fact]
public async Task Write_ToReadOnlyAttribute_IsRejected()
{
@@ -90,6 +99,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
finally { await fixture.DisposeAsync(); }
}
/// <summary>
/// Verifies that writes succeed for Galaxy attributes whose security classification permits operator updates.
/// </summary>
[Fact]
public async Task Write_ToReadWriteAttribute_Succeeds()
{

View File

@@ -27,6 +27,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
};
}
/// <summary>
/// Verifies that historized Galaxy attributes advertise OPC UA historizing support and history-read access.
/// </summary>
[Fact]
public async Task HistorizedAttribute_HasHistorizingTrue_AndHistoryReadAccess()
{
@@ -49,6 +52,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
finally { await fixture.DisposeAsync(); }
}
/// <summary>
/// Verifies that non-historized Galaxy attributes do not claim OPC UA history support.
/// </summary>
[Fact]
public async Task NormalAttribute_HasHistorizingFalse_AndNoHistoryReadAccess()
{

View File

@@ -11,6 +11,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
{
public class IncrementalSyncTests
{
/// <summary>
/// Verifies that adding a new Galaxy object and attribute causes the corresponding OPC UA node subtree to appear after sync.
/// </summary>
[Fact]
public async Task Sync_AddObject_NewNodeAppears()
{
@@ -56,6 +59,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
finally { await fixture.DisposeAsync(); }
}
/// <summary>
/// Verifies that removing a Galaxy object tears down the corresponding OPC UA subtree without affecting siblings.
/// </summary>
[Fact]
public async Task Sync_RemoveObject_NodeDisappears()
{
@@ -87,6 +93,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
finally { await fixture.DisposeAsync(); }
}
/// <summary>
/// Verifies that adding a Galaxy attribute creates a new OPC UA variable during incremental rebuild.
/// </summary>
[Fact]
public async Task Sync_AddAttribute_NewVariableAppears()
{
@@ -114,6 +123,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
finally { await fixture.DisposeAsync(); }
}
/// <summary>
/// Verifies that subscriptions on unchanged objects continue receiving data after unrelated subtree rebuilds.
/// </summary>
[Fact]
public async Task Sync_UnchangedObject_SubscriptionSurvives()
{
@@ -148,6 +160,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
finally { await fixture.DisposeAsync(); }
}
/// <summary>
/// Verifies that a rebuild request with no repository changes leaves the published namespace intact.
/// </summary>
[Fact]
public async Task Sync_NoChanges_NothingHappens()
{

View File

@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
private static GalaxyAttributeInfo Attr(int gobjectId, string name, string tagName = "Obj", int mxDataType = 5)
=> new GalaxyAttributeInfo { GobjectId = gobjectId, AttributeName = name, FullTagReference = $"{tagName}.{name}", MxDataType = mxDataType, TagName = tagName };
/// <summary>
/// Verifies that identical Galaxy hierarchy and attribute snapshots produce no incremental rebuild work.
/// </summary>
[Fact]
public void NoChanges_ReturnsEmptySet()
{
@@ -24,6 +27,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldBeEmpty();
}
/// <summary>
/// Verifies that newly deployed Galaxy objects are flagged for OPC UA subtree creation.
/// </summary>
[Fact]
public void AddedObject_Detected()
{
@@ -36,6 +42,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldNotContain(1);
}
/// <summary>
/// Verifies that removed Galaxy objects are flagged so their OPC UA subtree can be torn down.
/// </summary>
[Fact]
public void RemovedObject_Detected()
{
@@ -48,6 +57,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldNotContain(1);
}
/// <summary>
/// Verifies that browse-name changes are treated as address-space changes for the affected Galaxy object.
/// </summary>
[Fact]
public void ModifiedObject_BrowseNameChange_Detected()
{
@@ -59,6 +71,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldContain(1);
}
/// <summary>
/// Verifies that parent changes are treated as subtree moves that require rebuilding the affected object.
/// </summary>
[Fact]
public void ModifiedObject_ParentChange_Detected()
{
@@ -70,6 +85,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldContain(2);
}
/// <summary>
/// Verifies that adding a Galaxy attribute marks the owning object for OPC UA variable rebuild.
/// </summary>
[Fact]
public void AttributeAdded_Detected()
{
@@ -81,6 +99,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldContain(1);
}
/// <summary>
/// Verifies that removing a Galaxy attribute marks the owning object for OPC UA variable rebuild.
/// </summary>
[Fact]
public void AttributeRemoved_Detected()
{
@@ -92,6 +113,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldContain(1);
}
/// <summary>
/// Verifies that changes to attribute field metadata such as MX data type trigger rebuild of the owning object.
/// </summary>
[Fact]
public void AttributeFieldChange_Detected()
{
@@ -103,6 +127,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldContain(1);
}
/// <summary>
/// Verifies that security-classification changes are treated as address-space changes for the owning attribute.
/// </summary>
[Fact]
public void AttributeSecurityChange_Detected()
{
@@ -114,6 +141,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
changed.ShouldContain(1);
}
/// <summary>
/// Verifies that subtree expansion includes all descendants of a changed Galaxy object.
/// </summary>
[Fact]
public void ExpandToSubtrees_IncludesChildren()
{
@@ -136,6 +166,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
expanded.ShouldNotContain(5);
}
/// <summary>
/// Verifies that subtree expansion does not introduce unrelated nodes when the changed object is already a leaf.
/// </summary>
[Fact]
public void ExpandToSubtrees_LeafNode_NoExpansion()
{

View File

@@ -9,18 +9,34 @@ namespace OpcUaCli.Commands;
[Command("alarms", Description = "Subscribe to alarm events on a node")]
public class AlarmsCommand : ICommand
{
/// <summary>
/// Gets the OPC UA endpoint URL for the server whose alarm stream should be monitored.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
/// <summary>
/// Gets the node to subscribe to for event notifications, typically a source object or the server node.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID to monitor for events (default: Server node)")]
public string? NodeId { get; init; }
/// <summary>
/// Gets the requested publishing and sampling interval for the alarm subscription.
/// </summary>
[CommandOption("interval", 'i', Description = "Publishing interval in milliseconds")]
public int Interval { get; init; } = 1000;
/// <summary>
/// Gets a value indicating whether the command should request a retained-condition refresh after subscribing.
/// </summary>
[CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
public bool Refresh { get; init; }
/// <summary>
/// Connects to the target server and streams alarm or condition events to the operator console.
/// </summary>
/// <param name="console">The CLI console used for cancellation and alarm-event output.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);

View File

@@ -9,27 +9,52 @@ namespace OpcUaCli.Commands;
[Command("historyread", Description = "Read historical data from a node")]
public class HistoryReadCommand : ICommand
{
/// <summary>
/// Gets the OPC UA endpoint URL for the server that exposes the historized node.
/// </summary>
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
/// <summary>
/// Gets the node identifier for the historized variable to query.
/// </summary>
[CommandOption("node", 'n', Description = "Node ID (e.g. ns=1;s=TestMachine_001.TestHistoryValue)", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Gets the requested history start time string supplied by the operator.
/// </summary>
[CommandOption("start", Description = "Start time (ISO 8601 or date string, default: 24 hours ago)")]
public string? StartTime { get; init; }
/// <summary>
/// Gets the requested history end time string supplied by the operator.
/// </summary>
[CommandOption("end", Description = "End time (ISO 8601 or date string, default: now)")]
public string? EndTime { get; init; }
/// <summary>
/// Gets the maximum number of raw history values that should be returned to the console.
/// </summary>
[CommandOption("max", Description = "Maximum number of values to return")]
public int MaxValues { get; init; } = 1000;
/// <summary>
/// Gets the optional aggregate name to request when the operator wants processed history instead of raw values.
/// </summary>
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count")]
public string? Aggregate { get; init; }
/// <summary>
/// Gets the aggregate processing interval, in milliseconds, for processed history reads.
/// </summary>
[CommandOption("interval", Description = "Processing interval in milliseconds for aggregates")]
public double IntervalMs { get; init; } = 3600000;
/// <summary>
/// Connects to the target server and prints raw or aggregate historical data for the requested node.
/// </summary>
/// <param name="console">The CLI console used for output, errors, and cancellation handling.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url);