feat(server): equipment-tag node writability from Tag.AccessLevel (parity-safe, no migration)

This commit is contained in:
Joseph Doherty
2026-06-13 11:46:00 -04:00
parent f8f1027287
commit a23fb2b82e
15 changed files with 170 additions and 43 deletions
@@ -633,7 +633,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="parentFolderNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
/// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
/// ReadWrite equipment tag); when false it stays <c>CurrentRead</c> (read-only). This task only sets
/// the access level — no OnWriteValue handler is attached here (the inbound-write handler is owned
/// by a later task).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -655,8 +659,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = ResolveBuiltInDataType(dataType),
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
// The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
// CurrentRead | CurrentWrite. OR-ing two byte constants promotes to int, so cast back.
AccessLevel = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead,
UserAccessLevel = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead,
Historizing = false,
Value = null,
StatusCode = StatusCodes.BadWaitingForInitialData,
@@ -189,7 +189,7 @@ public sealed class Phase7Applier
? tag.EquipmentId
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType);
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable);
}
_logger.LogInformation(
@@ -236,7 +236,8 @@ public sealed class Phase7Applier
? v.EquipmentId
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
SafeEnsureVariable(nodeId, parent, v.Name, v.DataType);
// VirtualTags are computed outputs — read-only nodes (no inbound write).
SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false);
}
_logger.LogInformation(
@@ -281,9 +282,9 @@ public sealed class Phase7Applier
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
}
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType)
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable)
{
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType); }
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
}
@@ -71,7 +71,11 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
/// <see cref="DataType"/>, and the driver-side <see cref="FullName"/> reference (extracted from
/// <c>Tag.TagConfig</c>) the later values milestone routes reads/writes by. The variable's NodeId
/// is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref
/// (e.g. a Modbus register) is not unique across identical machines.
/// (e.g. a Modbus register) is not unique across identical machines. <see cref="Writable"/>
/// mirrors the authored <c>Tag.AccessLevel == ReadWrite</c> so the materialised node is created
/// <c>CurrentReadWrite</c> (the prerequisite for the inbound-write pipeline); a <c>Read</c> tag
/// stays read-only. This flag is derived identically on the artifact-decode side
/// (<c>DeploymentArtifact.BuildEquipmentTagPlans</c>) for byte-parity.
/// </summary>
public sealed record EquipmentTagPlan(
string TagId,
@@ -80,7 +84,8 @@ public sealed record EquipmentTagPlan(
string FolderPath,
string Name,
string DataType,
string FullName);
string FullName,
bool Writable);
/// <summary>
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
@@ -322,7 +327,8 @@ public static class Phase7Composer
FolderPath: t.FolderPath ?? string.Empty,
Name: t.Name,
DataType: t.DataType,
FullName: ExtractTagFullName(t.TagConfig)))
FullName: ExtractTagFullName(t.TagConfig),
Writable: t.AccessLevel == TagAccessLevel.ReadWrite))
.ToList();
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
@@ -56,8 +56,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
/// <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>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
/// <param name="writable">When true the node is created read/write; otherwise read-only.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable);
/// <summary>Rebuilds the entire OPC UA address space.</summary>
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();