docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// tear-down + rebuild.</item>
/// </list>
///
/// This is the side-effecting layer Task 47 deferred to F14. It stays pure-of-SDK so
/// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
/// and tests can capture every call.
/// </summary>
@@ -101,7 +101,7 @@ public sealed class AddressSpaceApplier
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count +
// OpcUaServer-001: a UNS Area/Line rename is an in-place change to an existing folder node.
// A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
@@ -122,7 +122,7 @@ public sealed class AddressSpaceApplier
// respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every
// client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/
// FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild.
// F10b + FB-7 (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
// F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
// IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no
// alarm condition node) can be updated IN PLACE on the existing node via
// ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full
@@ -140,7 +140,7 @@ public sealed class AddressSpaceApplier
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d));
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(TagDeltaIsSurgicalEligible).ToList();
// OpcUaServer-001 — UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
// covered for free and need no separate surgical pass.
@@ -383,7 +383,7 @@ public sealed class AddressSpaceApplier
}
/// <summary>
/// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// Build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
/// clients browsing the server see proper folder structure instead of flat tag ids.
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
@@ -420,19 +420,13 @@ public sealed class AddressSpaceApplier
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
/// Idempotent.
/// <para>
/// <b>Task 0 architecture decisions (recorded per the equipment-namespace-structure
/// plan).</b> Decision #1 = <b>A</b> — a sink-based pass, NOT a reuse of
/// <c>EquipmentNodeWalker</c>: no sink-backed <c>IAddressSpaceBuilder</c> adapter exists
/// (<c>GenericDriverNodeManager.CapturingBuilder</c> decorates another builder, not the
/// sink), and the walker re-creates the whole Area/Line/Equipment tree with browse-path
/// NodeIds — incompatible with this path's logical-Id NodeIds (decision #3) and the
/// already-materialised equipment folders (decision #4). Decision #4 = this pass adds
/// ONLY variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns
/// the equipment folders and this pass never re-creates them. The sink's
/// <c>EnsureVariable</c> takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// </para>
/// Idempotent. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
/// sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
/// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
/// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY
/// variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
/// equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
/// takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// </summary>
/// <param name="composition">The composition result containing the equipment tags to materialise.</param>
public void MaterialiseEquipmentTags(AddressSpaceComposition composition)
@@ -442,7 +436,7 @@ public sealed class AddressSpaceApplier
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (decision #4: never re-create
// equipment folder, which MaterialiseHierarchy already created (never re-create
// the equipment folder here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in composition.EquipmentTags)
@@ -641,7 +635,7 @@ public sealed class AddressSpaceApplier
Historize = d.Current.Historize,
}).Equals(d.Current);
// F10b + FB-7: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
@@ -650,7 +644,7 @@ public sealed class AddressSpaceApplier
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
// override-unequal default also covers any future field.
// REACH (live-verified FB-7): the shape path only fires for drivers whose TagConfig carries a stable
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
// AddressSpaceComposer.ExtractTagFullName falls back to the RAW TagConfig blob as FullName, so a DataType/
@@ -171,11 +171,11 @@ public sealed record EquipmentVirtualTagPlan(
IReadOnlyList<string> DependencyRefs,
bool Historize = false)
{
/// <summary>Structural equality: the auto-generated record equality would compare
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every
/// VirtualTag as "changed" on every parse (fresh list instances). Compare it element-wise
/// so a no-op redeploy diffs empty. <see cref="Historize"/> is included so a Historize-only
/// toggle is detected as a change.</summary>
/// <inheritdoc />
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
// interface-typed list) BY REFERENCE, flagging every VirtualTag as "changed" on every parse
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty. Historize
// is included so a Historize-only toggle is detected as a change.
public bool Equals(EquipmentVirtualTagPlan? other) =>
other is not null &&
VirtualTagId == other.VirtualTagId &&
@@ -187,6 +187,7 @@ public sealed record EquipmentVirtualTagPlan(
Historize == other.Historize &&
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
/// <inheritdoc />
public override int GetHashCode()
{
var hash = new HashCode();
@@ -238,17 +239,15 @@ public sealed record EquipmentScriptedAlarmPlan(
bool Retain,
bool Enabled)
{
/// <summary>Structural equality: the auto-generated record equality would compare
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every alarm as
/// "changed" on every parse (fresh list instances). Compare it element-wise so a no-op redeploy
/// diffs empty (mirrors <see cref="EquipmentVirtualTagPlan"/>).</summary>
/// <remarks>
/// <b>DependencyRefs equality is order-sensitive</b> (SequenceEqual).
/// <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> is the canonical, deterministic
/// producer of that order (predicate <c>ctx.GetTag</c> reads first, then first-seen message
/// template tokens). Downstream byte-parity between the live composer and the artifact-decode
/// mirror depends on both sides calling <c>ExtractAlarmDependencyRefs</c> with identical inputs.
/// </remarks>
/// <inheritdoc />
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
// interface-typed list) BY REFERENCE, flagging every alarm as "changed" on every parse (fresh
// list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
// EquipmentVirtualTagPlan). DependencyRefs equality is order-sensitive (SequenceEqual).
// EquipmentScriptPaths.ExtractAlarmDependencyRefs is the canonical, deterministic producer of
// that order (predicate ctx.GetTag reads first, then first-seen message template tokens).
// Downstream byte-parity between the live composer and the artifact-decode mirror depends on
// both sides calling ExtractAlarmDependencyRefs with identical inputs.
public bool Equals(EquipmentScriptedAlarmPlan? other) =>
other is not null &&
ScriptedAlarmId == other.ScriptedAlarmId &&
@@ -280,9 +279,9 @@ public sealed record EquipmentScriptedAlarmPlan(
/// <summary>
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
/// startup (Task 53) consumes the result and hands it to the node-manager factory.
/// startup consumes the result and hands it to the node-manager factory.
///
/// #85 — the composer now carries UNS topology (<see cref="UnsAreaProjection"/> +
/// The composer also carries UNS topology (<see cref="UnsAreaProjection"/> +
/// <see cref="UnsLineProjection"/>) so <c>AddressSpaceApplier</c> can build the
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
@@ -597,6 +596,8 @@ public static class AddressSpaceComposer
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed native-alarm intent, or <see langword="null"/> when the tag has no alarm object.</returns>
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
@@ -627,6 +628,8 @@ public static class AddressSpaceComposer
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
/// <c>ExtractTagAlarm</c>. Never throws. The artifact-decode side
/// (<c>DeploymentArtifact.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed historize flag and optional historian tagname override.</returns>
internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
@@ -657,6 +660,8 @@ public static class AddressSpaceComposer
/// <see cref="ExtractTagHistorize"/> exactly in structure + null/blank/non-object/malformed-JSON
/// tolerance. Never throws. The artifact-decode side
/// (<c>DeploymentArtifact.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed array flag and optional array length.</returns>
internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
@@ -53,7 +53,7 @@ public sealed record AddressSpacePlan(
public IReadOnlyList<EquipmentVirtualTagDelta> ChangedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagDelta>();
/// <summary>
/// OpcUaServer-001 — UNS Area / Line folder renames: a folder whose stable id is unchanged but
/// UNS Area / Line folder renames: a folder whose stable id is unchanged but
/// whose <c>DisplayName</c> differs between the previous + next composition. A deploy whose ONLY
/// change is an Area or Line rename produces NO Equipment / Driver / Alarm / Tag / VirtualTag
/// delta, so without this set the plan would be <see cref="IsEmpty"/> and
@@ -84,7 +84,7 @@ public sealed record AddressSpacePlan(
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
/// <summary>OpcUaServer-001 — one renamed UNS Area / Line folder: the stable folder
/// <summary>One renamed UNS Area / Line folder: the stable folder
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
/// to apply in place.</summary>
@@ -103,6 +103,7 @@ public static class AddressSpacePlanner
/// </summary>
/// <param name="previous">The previous composition result.</param>
/// <param name="next">The new composition result.</param>
/// <returns>The diff plan between the two compositions.</returns>
public static AddressSpacePlan Compute(AddressSpaceComposition previous, AddressSpaceComposition next)
{
ArgumentNullException.ThrowIfNull(previous);
@@ -138,7 +139,7 @@ public static class AddressSpacePlanner
t => t.VirtualTagId,
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
// OpcUaServer-001 — UNS Area / Line renames: a folder whose stable id is unchanged but whose
// UNS Area / Line renames: a folder whose stable id is unchanged but whose
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
// IS the area/line id (the exact scheme MaterialiseHierarchy uses), so the rename carries it
@@ -163,7 +164,7 @@ public static class AddressSpacePlanner
}
/// <summary>
/// OpcUaServer-001 — emit a <see cref="AddressSpacePlan.FolderRename"/> for every folder present in
/// Emit a <see cref="AddressSpacePlan.FolderRename"/> for every folder present in
/// BOTH snapshots (matched by stable <paramref name="identity"/>) whose <paramref name="displayName"/>
/// differs (ordinal). Added/removed folders are NOT renames — they're handled by the equipment /
/// hierarchy rebuild path — so this pass only flags an in-place display-name change on a surviving
@@ -122,6 +122,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// <summary>Starts the OPC UA application and server.</summary>
/// <param name="server">The standard server instance to start.</param>
/// <param name="cancellationToken">A cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
{
_server = server;
@@ -377,6 +378,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// listening endpoints — the misconfiguration is logged and very visible.
/// </summary>
/// <param name="profiles">The security profiles to build policies for.</param>
/// <returns>The SDK security policies for the requested profiles.</returns>
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
{
var seen = new HashSet<OpcUaSecurityProfile>();
@@ -424,6 +426,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// project supplies the LDAP adapter, and <see cref="NullOpcUaUserAuthenticator"/> denies all
/// UserName logins when none is registered.
/// </summary>
/// <returns>The Anonymous and UserName token policies.</returns>
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
{
yield return new UserTokenPolicy(UserTokenType.Anonymous)
@@ -439,6 +442,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
}
/// <summary>Disposes the application host and cleans up resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
@@ -58,7 +58,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
/// <summary>Phase C (Task 4): event-notifier folder NodeId-identifier → the event-history source
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
@@ -168,7 +168,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// this source — so a single registered historian (e.g. Wonderware) serves many drivers' nodes,
/// independent of any driver's lifecycle.
/// <para>
/// Set by the Host at <c>StartAsync</c> (Task 5). The <see cref="NullHistorianDataSource"/>
/// Set by the Host at <c>StartAsync</c>. The <see cref="NullHistorianDataSource"/>
/// default (assigning <c>null</c> restores it) means "no historian wired" → every read
/// returns empty, so a historized node's HistoryRead surfaces <c>GoodNoData</c> rather than
/// faulting. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make
@@ -261,7 +261,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
lock (Lock)
{
@@ -297,7 +297,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(state);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
// Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
// _alarmConditions / detach the condition node between the lookup and the Set* calls.
@@ -573,6 +573,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
/// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
/// <param name="severity">Domain severity (treated as an OPC UA 1..1000 severity); mapped to <see cref="EventSeverity"/>.</param>
/// <param name="isNative">True when the condition is a NATIVE (driver-fed, e.g. Galaxy) alarm rather than a scripted one.</param>
/// <remarks>
/// <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
@@ -586,7 +587,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
lock (Lock)
{
@@ -693,7 +694,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
// "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
// AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
// engine enable/disable surface (Phase 3 decision #2) — they short-circuit to BadNotSupported.
// engine enable/disable surface — they short-circuit to BadNotSupported.
alarm.OnEnableDisable = (context, condition, enabling) =>
{
if (IsNativeAlarmNode(alarmNodeId))
@@ -720,6 +721,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
/// <param name="alarmNodeId">The alarm condition node id.</param>
/// <returns>True when the condition is native (driver-fed); false when it is scripted or not found.</returns>
internal bool IsNativeAlarmNode(string alarmNodeId)
{
// _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition /
@@ -811,7 +813,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
/// <see cref="EnsureVariable"/> (Task 11). The OPC UA SDK invokes it when a client writes the
/// <see cref="EnsureVariable"/>. The OPC UA SDK invokes it when a client writes the
/// node's Value. It resolves the calling principal off the SDK <paramref name="context"/> the
/// SAME way <see cref="HandleAlarmCommand"/> does, gates on the
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role + the gateway being wired
@@ -1230,7 +1232,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
/// <remarks>
/// Phase C (Task 4): when a real historian is wired at promotion time (the source is NOT the
/// Phase C: when a real historian is wired at promotion time (the source is NOT the
/// <see cref="NullHistorianDataSource"/>), the folder ALSO gets the
/// <see cref="EventNotifiers.HistoryRead"/> bit OR-ed in (keeping SubscribeToEvents) and registers
/// its NodeId identifier as an event-history source so the <see cref="HistoryReadEvents"/> override
@@ -1272,7 +1274,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
/// #85 — used by <see cref="AddressSpaceApplier"/> to materialise the UNS Area/Line/Equipment
/// Used by <see cref="AddressSpaceApplier"/> to materialise the UNS Area/Line/Equipment
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
/// folder so adding child variables under it still works.
/// </summary>
@@ -1283,7 +1285,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
if (_folders.ContainsKey(folderNodeId)) return;
@@ -1310,7 +1312,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>
/// Update an EXISTING folder node's <see cref="FolderState.DisplayName"/> in place and notify
/// subscribers, WITHOUT a rebuild — the in-place counterpart of <see cref="EnsureFolder"/> for a
/// UNS Area / Line rename (OpcUaServer-001). <see cref="EnsureFolder"/> early-returns for an
/// UNS Area / Line rename. <see cref="EnsureFolder"/> early-returns for an
/// already-present folder id and never touches an existing folder's display name, so a
/// rename-only deploy would otherwise be invisible until a full <see cref="RebuildAddressSpace"/>
/// cleared <c>_folders</c>. The NodeId + BrowseName are unchanged (a rename only touches the
@@ -1351,7 +1353,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
/// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
/// ReadWrite equipment tag) and the inbound-write handler <see cref="OnEquipmentTagWrite"/> is attached
/// to its <c>OnWriteValue</c> (Task 11) so a client write gates on the <c>WriteOperate</c> role + routes
/// to its <c>OnWriteValue</c> so a client write gates on the <c>WriteOperate</c> role + routes
/// to the backing driver; when false it stays <c>CurrentRead</c> (read-only) with no write handler.</param>
/// <param name="historianTagname">Phase C: null ⇒ the node is NOT historized (Historizing=false, no
/// HistoryRead bit, not registered). Non-null ⇒ the node is created <c>Historizing</c> with the
@@ -1368,7 +1370,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
// If already present, leave it alone (idempotent re-applies).
if (_variables.ContainsKey(variableNodeId)) return;
@@ -1401,7 +1403,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
StatusCode = StatusCodes.BadWaitingForInitialData,
Timestamp = DateTime.MinValue,
};
// Task 11: a writable equipment tag owns an inbound-write handler. The SDK invokes
// A writable equipment tag owns an inbound-write handler. The SDK invokes
// OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
// driver via NodeWriteGateway. Read-only nodes leave the handler unattached so a write is
// rejected by the SDK's own AccessLevel check before it ever reaches a handler.
@@ -1437,7 +1439,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// in place and notify subscribers, WITHOUT a rebuild — so client MonitoredItems on the node survive.
/// Returns false when the node id is unknown (caller falls back to a full rebuild).
/// <para>
/// FB-7: when the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
/// When the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
/// happen. (1) The node's value is reset to <c>BadWaitingForInitialData</c> (Value=null) so no value
/// still typed to the OLD DataType is exposed between this update and the driver's next publish — the
/// same fresh-node state <see cref="EnsureVariable"/> creates, which closes the "brief value-type
@@ -1476,7 +1478,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (historized) _historizedTagnames[variableNodeId] = historianTagname!;
else _historizedTagnames.TryRemove(variableNodeId, out _);
// FB-7: swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ
// Swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ
// from the live node — a Writable/Historizing-only change leaves the shape untouched (no value
// reset, no model-change event), preserving the original surgical behaviour byte-for-byte.
var newDataType = ResolveBuiltInDataType(dataType);
@@ -1727,7 +1729,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
// equipment folders to event notifiers.
_notifierFolders.Clear();
// Phase C (Task 4): drop the event-history source registrations alongside the notifier folders
// Phase C: drop the event-history source registrations alongside the notifier folders
// they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
_eventNotifierSources.Clear();
}
@@ -1740,7 +1742,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
}
/// <summary>OpcUaServer-004: guard the address-space mutators against a too-early call. <c>_root</c>
/// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
/// is only assigned in <see cref="CreateAddressSpace"/>, which the SDK invokes during
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>,
/// <see cref="WriteAlarmCondition"/>, <see cref="EnsureFolder"/>, <see cref="EnsureVariable"/>,
@@ -1766,7 +1768,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// that carry the HistoryRead access bit), validates the timestamp args, handles
// `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
// protected virtuals below. We override all four arms: the three variable-history virtuals
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents, Task 4). Each override
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents). Each override
// receives the pre-filtered handles and fills results[handle.Index] / errors[handle.Index] —
// handle.Index is the original index into the service-level results/errors lists, seeded by the
// base. The base pre-seeds every handle's error to BadHistoryOperationUnsupported, so a handle
@@ -1778,18 +1780,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// backend throw) never throws out of the batch.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Serve a HistoryRead-Raw request over the pre-filtered historized variable handles, dispatching
/// each to <see cref="IHistorianDataSource.ReadRawAsync"/>. Modified-history reads
/// (<c>IsReadModified</c>) are unsupported — we don't serve a modified-value history surface.
/// <para>
/// Raw is the only arm that pages server-side: <c>ReadRawModifiedDetails</c> carries a client
/// count cap (<c>NumValuesPerNode</c>), so a page that returns exactly that many samples MAY
/// have more behind it ⇒ a time-based continuation point is emitted (see
/// <see cref="ServeRawPaged"/>). An inbound continuation point on a node resumes its stored
/// read. <c>NumValuesPerNode == 0</c> ("all values") never pages.
/// </para>
/// </summary>
/// <inheritdoc />
protected override void HistoryReadRawModified(
ServerSystemContext context,
ReadRawModifiedDetails details,
@@ -1816,15 +1807,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Serve a HistoryRead-Processed request, mapping each node's per-node aggregate NodeId (from the
/// parallel <c>AggregateType</c> collection — the base guarantees it is the same length as
/// <c>nodesToRead</c>) to a <see cref="HistoryAggregateType"/> and dispatching to
/// <see cref="IHistorianDataSource.ReadProcessedAsync"/>. An unknown aggregate yields
/// <c>BadAggregateNotSupported</c> for that node. Single-shot (no continuation point):
/// <c>ReadProcessedDetails</c> carries no client count cap — the bucket count is deterministic
/// (window / interval) — so there is no "full page" signal to page on.
/// </summary>
/// <inheritdoc />
protected override void HistoryReadProcessed(
ServerSystemContext context,
ReadProcessedDetails details,
@@ -1864,12 +1847,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Serve a HistoryRead-AtTime request, dispatching the requested timestamps to
/// <see cref="IHistorianDataSource.ReadAtTimeAsync"/>. Single-shot (no continuation point):
/// AtTime carries no client count cap — the request IS the timestamp list and the result is
/// exactly one sample per requested timestamp — so there is no "full page" signal to page on.
/// </summary>
/// <inheritdoc />
protected override void HistoryReadAtTime(
ServerSystemContext context,
ReadAtTimeDetails details,
@@ -1891,18 +1869,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Serve a HistoryRead-Events request over the equipment-folder event-notifier nodes (the folders
/// that own alarm conditions). Each handle's NodeId identifier is resolved against
/// <see cref="_eventNotifierSources"/>: a miss ⇒ <c>BadHistoryOperationUnsupported</c> (a node we own
/// that isn't a registered event-history source — e.g. a plain folder, or one promoted while no
/// historian was wired); a hit block-bridges to <see cref="IHistorianDataSource.ReadEventsAsync"/>
/// for the folder's source name and projects each <see cref="HistoricalEvent"/> into a
/// <see cref="HistoryEventFieldList"/> per the request's event filter. Like the Raw/Processed/AtTime
/// arms this is NOT invoked under the node-manager <c>Lock</c>, so the block-bridge is safe; each
/// handle is served under try/catch so a backend throw becomes a Bad status for THAT node only and
/// never throws out of the batch.
/// </summary>
/// <inheritdoc />
protected override void HistoryReadEvents(
ServerSystemContext context,
ReadEventDetails details,
@@ -1936,7 +1903,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
sourceName,
details.StartTime,
details.EndTime,
// OpcUaServer-002: NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA
// Part 4/11, so translate it to UNBOUNDED (int.MaxValue) here. Passing the int<=0
// backend-default-cap sentinel instead would silently truncate a "give me everything"
// events read at the backend default. A positive cap passes through (clamped).
@@ -1974,7 +1941,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <returns>The clamped non-negative int.</returns>
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
/// <summary>OpcUaServer-002: map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
/// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> <c>maxEvents</c> argument, honouring the OPC UA
/// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
/// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
@@ -2315,14 +2282,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Drop the resume state for any continuation points the client asked to release
/// (<c>releaseContinuationPoints == true</c>) and return WITHOUT reading data, per OPC UA Part 4.
/// The base dispatcher routes a release-only HistoryRead here (it never reaches the per-details
/// arms), so this is the single place that must free Raw's stored cursors. Each handle's released
/// point is <c>nodesToRead[handle.Index].ContinuationPoint</c>; releasing an unknown / null point
/// is a harmless no-op. Errors are left Good (the base pre-seeds them) — a release does not fail.
/// </summary>
/// <inheritdoc />
protected override void HistoryReleaseContinuationPoints(
ServerSystemContext context,
IList<HistoryReadValueId> nodesToRead,
@@ -20,76 +20,37 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
_nodeManager = nodeManager;
}
/// <summary>Writes a value to the OPC UA address space.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="value">The value being written.</param>
/// <param name="quality">The OPC UA quality status.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
/// <summary>Writes the full Part 9 alarm-condition state to the OPC UA address space.</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
/// <summary>Materialises a real Part 9 alarm-condition node in the address space.</summary>
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node identifier the condition parents under.</param>
/// <param name="displayName">The human-readable condition name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
/// <param name="isNative">True for a driver-fed (native) equipment-tag alarm; false (default) for a scripted alarm.</param>
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
/// <summary>Ensures a folder node exists in the address space.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
/// <summary>Ensures a variable node exists in the address space.</summary>
/// <param name="variableNodeId">The variable node identifier.</param>
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA data type.</param>
/// <param name="writable">When true the node is created read/write; otherwise read-only.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ create Historizing with the
/// HistoryRead access bit and register the historian tagname.</param>
/// <param name="isArray">When true the node is created as a 1-D array; when false (default) scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
/// <summary>F10b: surgically update an existing variable node's Writable + Historizing + presentation
/// shape (DataType / array-ness) in place (no rebuild). Returns false when the node does not exist
/// (caller falls back to a full rebuild).</summary>
/// <param name="variableNodeId">The variable node identifier.</param>
/// <param name="writable">When true the node becomes read/write with the inbound-write handler; otherwise read-only.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding.</param>
/// <param name="dataType">The OPC UA built-in data type name to apply in place.</param>
/// <param name="isArray">When true the node becomes a 1-D array; when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true.</param>
/// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
/// <summary>OpcUaServer-001: surgically update an existing folder node's display name in place (no
/// rebuild) for a UNS Area / Line rename. Returns false when the folder does not exist (caller falls
/// back to a full rebuild).</summary>
/// <param name="folderNodeId">The folder node identifier whose display name to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the in-place update was applied; false when the folder is missing.</returns>
/// <inheritdoc />
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName);
/// <summary>Rebuilds the entire OPC UA address space.</summary>
/// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
/// <summary>Announces a runtime NodeAdded model-change (discovered-node injection) to subscribed clients.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId);
}
@@ -32,8 +32,7 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
_logger = logger;
}
/// <summary>Publishes the service level to the OPC UA Server object.</summary>
/// <param name="serviceLevel">The service level value to publish.</param>
/// <inheritdoc />
public void Publish(byte serviceLevel)
{
var node = _serverInternal.ServerObject?.ServiceLevel;
@@ -55,11 +55,7 @@ public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
public static readonly NullOpcUaUserAuthenticator Instance = new();
private NullOpcUaUserAuthenticator() { }
/// <summary>Authenticates a username (always denies in this null implementation).</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The cleartext password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns a denial result indicating no authenticator is configured.</returns>
/// <inheritdoc />
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
}
@@ -25,7 +25,7 @@ public static class OpcUaDataPlaneRoles
/// <summary>The role that grants authority to write a writable equipment-tag variable node
/// (FreeAccess / Operate attributes). A session must carry this role for the inbound
/// <c>OnWriteValue</c> handler (Task 11) to route the value to the backing driver; absent it the
/// <c>OnWriteValue</c> handler to route the value to the backing driver; absent it the
/// write is denied with <c>BadUserAccessDenied</c> before any driver call.</summary>
public const string WriteOperate = "WriteOperate";
}