feat(server): equipment-tag node writability from Tag.AccessLevel (parity-safe, no migration)
This commit is contained in:
@@ -58,8 +58,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
|
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
|
||||||
/// <param name="displayName">The display name of the variable.</param>
|
/// <param name="displayName">The display name of the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA data type of the variable.</param>
|
/// <param name="dataType">The OPC UA data type of the variable.</param>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
/// <param name="writable">When true the node is created read/write; otherwise read-only.</param>
|
||||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
|
||||||
|
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable);
|
||||||
|
|
||||||
/// <summary>Rebuilds the address space through the inner sink.</summary>
|
/// <summary>Rebuilds the address space through the inner sink.</summary>
|
||||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// <param name="parentFolderNodeId">The parent folder node ID, or null for namespace root.</param>
|
/// <param name="parentFolderNodeId">The parent folder node ID, or null for namespace root.</param>
|
||||||
/// <param name="displayName">The display name for the variable.</param>
|
/// <param name="displayName">The display name for the variable.</param>
|
||||||
/// <param name="dataType">OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).</param>
|
/// <param name="dataType">OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).</param>
|
||||||
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). Non-equipment-tag
|
||||||
|
/// variables (folders' children, alarm placeholders) always pass <c>false</c>.</param>
|
||||||
|
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||||
@@ -95,7 +98,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
|
|||||||
@@ -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="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="displayName">The display name of the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</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(variableNodeId);
|
||||||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||||||
@@ -655,8 +659,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||||
DataType = ResolveBuiltInDataType(dataType),
|
DataType = ResolveBuiltInDataType(dataType),
|
||||||
ValueRank = ValueRanks.Scalar,
|
ValueRank = ValueRanks.Scalar,
|
||||||
AccessLevel = AccessLevels.CurrentRead,
|
// The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
|
||||||
UserAccessLevel = AccessLevels.CurrentRead,
|
// 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,
|
Historizing = false,
|
||||||
Value = null,
|
Value = null,
|
||||||
StatusCode = StatusCodes.BadWaitingForInitialData,
|
StatusCode = StatusCodes.BadWaitingForInitialData,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ public sealed class Phase7Applier
|
|||||||
? tag.EquipmentId
|
? tag.EquipmentId
|
||||||
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
|
||||||
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
|
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(
|
_logger.LogInformation(
|
||||||
@@ -236,7 +236,8 @@ public sealed class Phase7Applier
|
|||||||
? v.EquipmentId
|
? v.EquipmentId
|
||||||
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
|
||||||
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
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(
|
_logger.LogInformation(
|
||||||
@@ -281,9 +282,9 @@ public sealed class Phase7Applier
|
|||||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
|
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); }
|
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
|
/// <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
|
/// <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
|
/// 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>
|
/// </summary>
|
||||||
public sealed record EquipmentTagPlan(
|
public sealed record EquipmentTagPlan(
|
||||||
string TagId,
|
string TagId,
|
||||||
@@ -80,7 +84,8 @@ public sealed record EquipmentTagPlan(
|
|||||||
string FolderPath,
|
string FolderPath,
|
||||||
string Name,
|
string Name,
|
||||||
string DataType,
|
string DataType,
|
||||||
string FullName);
|
string FullName,
|
||||||
|
bool Writable);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
|
/// 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,
|
FolderPath: t.FolderPath ?? string.Empty,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
DataType: t.DataType,
|
DataType: t.DataType,
|
||||||
FullName: ExtractTagFullName(t.TagConfig)))
|
FullName: ExtractTagFullName(t.TagConfig),
|
||||||
|
Writable: t.AccessLevel == TagAccessLevel.ReadWrite))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
|
// 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="parentFolderNodeId">The parent folder node identifier.</param>
|
||||||
/// <param name="displayName">The display name for the variable.</param>
|
/// <param name="displayName">The display name for the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA data type.</param>
|
/// <param name="dataType">The OPC UA data type.</param>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
/// <param name="writable">When true the node is created read/write; otherwise read-only.</param>
|
||||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
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>
|
/// <summary>Rebuilds the entire OPC UA address space.</summary>
|
||||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
@@ -417,6 +418,17 @@ public static class DeploymentArtifact
|
|||||||
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
|
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
|
||||||
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||||
? tcEl.GetString() : null;
|
? tcEl.GetString() : null;
|
||||||
|
// AccessLevel → Writable. ConfigComposer serialises the TagAccessLevel enum WITHOUT a
|
||||||
|
// string converter, so it lands as a number (Read = 0, ReadWrite = 1); tolerate the string
|
||||||
|
// form ("ReadWrite") too — same defensive both-forms parse as the Kind gate above. MUST match
|
||||||
|
// Phase7Composer's `AccessLevel == TagAccessLevel.ReadWrite` exactly (byte-parity). A missing
|
||||||
|
// field defaults to non-writable (read-only).
|
||||||
|
var writable = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Number => alEl.GetInt32() == (int)TagAccessLevel.ReadWrite,
|
||||||
|
JsonValueKind.String => string.Equals(alEl.GetString(), nameof(TagAccessLevel.ReadWrite), StringComparison.Ordinal),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
|
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
|
||||||
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
|
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
|
||||||
@@ -432,7 +444,8 @@ public static class DeploymentArtifact
|
|||||||
FolderPath: folder ?? string.Empty,
|
FolderPath: folder ?? string.Empty,
|
||||||
Name: name!,
|
Name: name!,
|
||||||
DataType: dataType ?? "BaseDataType",
|
DataType: dataType ?? "BaseDataType",
|
||||||
FullName: ExtractTagFullName(tagConfig)));
|
FullName: ExtractTagFullName(tagConfig),
|
||||||
|
Writable: writable));
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Sort((a, b) =>
|
result.Sort((a, b) =>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
|||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
|
||||||
=> CallQueue.Enqueue($"EV:{variableNodeId}");
|
=> CallQueue.Enqueue($"EV:{variableNodeId}");
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,7 +264,8 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|||||||
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
|
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
|
||||||
/// <param name="displayName">The display name of the variable.</param>
|
/// <param name="displayName">The display name of the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
/// <param name="writable">Whether the node is created read/write.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
|
||||||
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
|
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,14 +131,15 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
applier.MaterialiseEquipmentTags(composition);
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
|
||||||
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float"));
|
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
|
||||||
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
|
||||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
|
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
|
||||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||||
}
|
}
|
||||||
@@ -157,14 +158,15 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
|
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
applier.MaterialiseEquipmentTags(composition);
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
|
||||||
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float"));
|
// A Read plan threads Writable: false (the node stays CurrentRead).
|
||||||
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
|
||||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
|
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
|
||||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
|
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
|
||||||
}
|
}
|
||||||
@@ -183,16 +185,16 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
||||||
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
applier.MaterialiseEquipmentTags(composition);
|
applier.MaterialiseEquipmentTags(composition);
|
||||||
|
|
||||||
sink.VariableCalls.Count.ShouldBe(2);
|
sink.VariableCalls.Count.ShouldBe(2);
|
||||||
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float"));
|
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false));
|
||||||
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float"));
|
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
|
/// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
|
||||||
@@ -218,7 +220,8 @@ public sealed class Phase7ApplierTests
|
|||||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||||
|
|
||||||
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
||||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
|
// VirtualTags are computed outputs — always read-only (Writable: false).
|
||||||
|
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
||||||
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
|
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
|
||||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm"));
|
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm"));
|
||||||
}
|
}
|
||||||
@@ -240,8 +243,8 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
||||||
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
|
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false),
|
||||||
},
|
},
|
||||||
EquipmentVirtualTags = new[]
|
EquipmentVirtualTags = new[]
|
||||||
{
|
{
|
||||||
@@ -286,8 +289,8 @@ public sealed class Phase7ApplierTests
|
|||||||
|
|
||||||
sink.FolderCalls.ShouldBeEmpty();
|
sink.FolderCalls.ShouldBeEmpty();
|
||||||
sink.VariableCalls.Count.ShouldBe(2);
|
sink.VariableCalls.Count.ShouldBe(2);
|
||||||
sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
|
sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
||||||
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64"));
|
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
|
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
|
||||||
@@ -335,7 +338,7 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
AddedEquipmentTags = new[]
|
AddedEquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,7 +397,7 @@ public sealed class Phase7ApplierTests
|
|||||||
/// <summary>Gets the queue of folder creation calls.</summary>
|
/// <summary>Gets the queue of folder creation calls.</summary>
|
||||||
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
|
||||||
/// <summary>Gets the queue of variable creation calls.</summary>
|
/// <summary>Gets the queue of variable creation calls.</summary>
|
||||||
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
|
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new();
|
||||||
/// <summary>Gets the queue of alarm-condition materialise calls.</summary>
|
/// <summary>Gets the queue of alarm-condition materialise calls.</summary>
|
||||||
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
|
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
|
||||||
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
|
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
|
||||||
@@ -405,7 +408,7 @@ public sealed class Phase7ApplierTests
|
|||||||
/// <summary>Gets the list of recorded folder creation calls.</summary>
|
/// <summary>Gets the list of recorded folder creation calls.</summary>
|
||||||
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
|
||||||
/// <summary>Gets the list of recorded variable creation calls.</summary>
|
/// <summary>Gets the list of recorded variable creation calls.</summary>
|
||||||
public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
|
public List<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableCalls => VariableQueue.ToList();
|
||||||
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
|
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
|
||||||
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
|
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
|
||||||
|
|
||||||
@@ -440,8 +443,9 @@ public sealed class Phase7ApplierTests
|
|||||||
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
||||||
/// <param name="displayName">The display name for the variable.</param>
|
/// <param name="displayName">The display name for the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
/// <param name="writable">Whether the node is created read/write.</param>
|
||||||
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
|
||||||
|
=> VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||||
/// <summary>Records a rebuild address space call.</summary>
|
/// <summary>Records a rebuild address space call.</summary>
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
}
|
}
|
||||||
@@ -485,7 +489,8 @@ public sealed class Phase7ApplierTests
|
|||||||
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
|
||||||
/// <param name="displayName">The display name for the variable.</param>
|
/// <param name="displayName">The display name for the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
/// <param name="writable">Whether the node is created read/write.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
|
||||||
/// <summary>No-op rebuild address space call.</summary>
|
/// <summary>No-op rebuild address space call.</summary>
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class Phase7PlannerTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+87
@@ -62,6 +62,80 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
tag.DataType.ShouldBe("Int32");
|
tag.DataType.ShouldBe("Int32");
|
||||||
tag.FolderPath.ShouldBe(string.Empty);
|
tag.FolderPath.ShouldBe(string.Empty);
|
||||||
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
|
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
|
||||||
|
// No AccessLevel in the blob → defaults to non-writable (read-only node).
|
||||||
|
tag.Writable.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The artifact decoder reads <c>AccessLevel</c> into <c>EquipmentTagPlan.Writable</c>:
|
||||||
|
/// ReadWrite → true, Read → false. ConfigComposer emits the enum numerically (no string converter),
|
||||||
|
/// so the numeric form (1 = ReadWrite) is the canonical wire shape, but the decoder also tolerates
|
||||||
|
/// the string form ("ReadWrite") defensively — mirroring how the Kind gate accepts both.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, true)] // numeric ReadWrite
|
||||||
|
[InlineData(0, false)] // numeric Read
|
||||||
|
public void ParseComposition_maps_numeric_AccessLevel_to_Writable(int accessLevel, bool expectedWritable)
|
||||||
|
{
|
||||||
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
|
||||||
|
DriverInstances = new[]
|
||||||
|
{
|
||||||
|
new { DriverInstanceId = "drv", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
|
||||||
|
},
|
||||||
|
Tags = new object[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
TagId = "tag-1",
|
||||||
|
DriverInstanceId = "drv",
|
||||||
|
EquipmentId = "eq-1",
|
||||||
|
Name = "Speed",
|
||||||
|
FolderPath = (string?)null,
|
||||||
|
DataType = "Float",
|
||||||
|
AccessLevel = accessLevel,
|
||||||
|
TagConfig = "{\"FullName\":\"40001\"}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var c = DeploymentArtifact.ParseComposition(blob);
|
||||||
|
|
||||||
|
c.EquipmentTags.ShouldHaveSingleItem().Writable.ShouldBe(expectedWritable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The decoder also tolerates the string enum form ("ReadWrite"/"Read") in case a future
|
||||||
|
/// serializer registers a string converter — byte-parity safety, mirroring the Kind gate.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ReadWrite", true)]
|
||||||
|
[InlineData("Read", false)]
|
||||||
|
public void ParseComposition_maps_string_AccessLevel_to_Writable(string accessLevel, bool expectedWritable)
|
||||||
|
{
|
||||||
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
|
||||||
|
DriverInstances = new[]
|
||||||
|
{
|
||||||
|
new { DriverInstanceId = "drv", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
|
||||||
|
},
|
||||||
|
Tags = new object[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
TagId = "tag-1",
|
||||||
|
DriverInstanceId = "drv",
|
||||||
|
EquipmentId = "eq-1",
|
||||||
|
Name = "Speed",
|
||||||
|
FolderPath = (string?)null,
|
||||||
|
DataType = "Float",
|
||||||
|
AccessLevel = accessLevel,
|
||||||
|
TagConfig = "{\"FullName\":\"40001\"}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var c = DeploymentArtifact.ParseComposition(blob);
|
||||||
|
|
||||||
|
c.EquipmentTags.ShouldHaveSingleItem().Writable.ShouldBe(expectedWritable);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An equipment-scoped tag in a non-Equipment (Simulated) namespace must NOT surface in
|
/// <summary>An equipment-scoped tag in a non-Equipment (Simulated) namespace must NOT surface in
|
||||||
@@ -239,6 +313,7 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
d.Name.ShouldBe(x.Name);
|
d.Name.ShouldBe(x.Name);
|
||||||
d.DataType.ShouldBe(x.DataType);
|
d.DataType.ShouldBe(x.DataType);
|
||||||
d.FullName.ShouldBe(x.FullName);
|
d.FullName.ShouldBe(x.FullName);
|
||||||
|
d.Writable.ShouldBe(x.Writable);
|
||||||
}
|
}
|
||||||
|
|
||||||
var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy");
|
var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy");
|
||||||
@@ -246,6 +321,15 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
galaxyPlan.EquipmentId.ShouldBe("eq-galaxy");
|
galaxyPlan.EquipmentId.ShouldBe("eq-galaxy");
|
||||||
galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy");
|
galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy");
|
||||||
galaxyPlan.FolderPath.ShouldBe(string.Empty); // null FolderPath coalesced identically on both sides
|
galaxyPlan.FolderPath.ShouldBe(string.Empty); // null FolderPath coalesced identically on both sides
|
||||||
|
|
||||||
|
// Writability flows from Tag.AccessLevel: the Galaxy tag is Read (read-only node), the Modbus
|
||||||
|
// tag is ReadWrite (writable node). Both producers must derive the same Writable flag, and the
|
||||||
|
// SequenceEqual above already proves they agree element-wise.
|
||||||
|
galaxyPlan.Writable.ShouldBeFalse(); // AccessLevel = Read
|
||||||
|
var modbusPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-modbus");
|
||||||
|
modbusPlan.Writable.ShouldBeTrue(); // AccessLevel = ReadWrite
|
||||||
|
composed.EquipmentTags.Single(t => t.TagId == "tag-galaxy").Writable.ShouldBeFalse();
|
||||||
|
composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Writable.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The full Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the
|
/// <summary>The full Pascal-case snapshot a <see cref="Tag"/> EF entity serialises to in the
|
||||||
@@ -258,6 +342,9 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
t.Name,
|
t.Name,
|
||||||
t.FolderPath,
|
t.FolderPath,
|
||||||
t.DataType,
|
t.DataType,
|
||||||
|
// ConfigComposer serialises with no JsonStringEnumConverter, so the TagAccessLevel enum lands
|
||||||
|
// as its numeric value (Read = 0, ReadWrite = 1) — exactly like Kind = (int)ns.Kind above.
|
||||||
|
AccessLevel = (int)t.AccessLevel,
|
||||||
t.TagConfig,
|
t.TagConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -217,7 +217,8 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
|||||||
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
|
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
|
||||||
/// <param name="displayName">The display name for the variable.</param>
|
/// <param name="displayName">The display name for the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
/// <param name="writable">Whether the node is created read/write.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
|
||||||
/// <summary>Rebuilds address space (recorded via span).</summary>
|
/// <summary>Rebuilds address space (recorded via span).</summary>
|
||||||
public void RebuildAddressSpace() { /* recorded via span */ }
|
public void RebuildAddressSpace() { /* recorded via span */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,7 +287,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
|||||||
/// <param name="parentFolderNodeId">The parent folder node ID, or null if this is a root variable.</param>
|
/// <param name="parentFolderNodeId">The parent folder node ID, or null if this is a root variable.</param>
|
||||||
/// <param name="displayName">The display name of the variable.</param>
|
/// <param name="displayName">The display name of the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
/// <param name="writable">Whether the node is created read/write.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
|
||||||
=> Calls.Enqueue($"EV:{variableNodeId}");
|
=> Calls.Enqueue($"EV:{variableNodeId}");
|
||||||
/// <summary>Records a rebuild address space call.</summary>
|
/// <summary>Records a rebuild address space call.</summary>
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
|
|||||||
@@ -208,7 +208,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
/// <param name="parentFolderNodeId">The parent folder node identifier, or null for root.</param>
|
/// <param name="parentFolderNodeId">The parent folder node identifier, or null for root.</param>
|
||||||
/// <param name="displayName">The display name of the variable.</param>
|
/// <param name="displayName">The display name of the variable.</param>
|
||||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
/// <param name="writable">Whether the node is created read/write.</param>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
|
||||||
|
|
||||||
/// <summary>Records a rebuild call.</summary>
|
/// <summary>Records a rebuild call.</summary>
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||||
|
|||||||
Reference in New Issue
Block a user