diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
index 14ecb5c5..ab57b959 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -58,8 +58,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
/// The node ID of the parent folder, or null for root.
/// The display name of the variable.
/// The OPC UA data type of the variable.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
- => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
+ /// When true the node is created read/write; otherwise read-only.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
+ => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable);
/// Rebuilds the address space through the inner sink.
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
index cd63287f..8357fe38 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
@@ -62,7 +62,10 @@ public interface IOpcUaAddressSpaceSink
/// The parent folder node ID, or null for namespace root.
/// The display name for the variable.
/// OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).
- void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType);
+ /// When true the node is created CurrentReadWrite (an authored
+ /// ReadWrite equipment tag); when false it stays CurrentRead (read-only). Non-equipment-tag
+ /// variables (folders' children, alarm placeholders) always pass false.
+ void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable);
///
/// Tear down + repopulate the address space. Called by OpcUaPublishActor after a
@@ -95,7 +98,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
///
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
///
public void RebuildAddressSpace() { }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index ebc52232..c2672aae 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -633,7 +633,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The node identifier of the parent folder; null to use the namespace root.
/// The display name of the variable.
/// The OPC UA data type name (e.g., "Boolean", "Int32", "String").
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
+ /// When true the node is created CurrentReadWrite (an authored
+ /// ReadWrite equipment tag); when false it stays CurrentRead (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).
+ 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,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
index 89e3643e..bdc97367 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs
@@ -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); }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
index 79da6bd1..db7f45f1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
@@ -71,7 +71,11 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
/// , and the driver-side reference (extracted from
/// Tag.TagConfig) the later values milestone routes reads/writes by. The variable's NodeId
/// is folder-scoped (parent/Name), NOT , 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.
+/// mirrors the authored Tag.AccessLevel == ReadWrite so the materialised node is created
+/// CurrentReadWrite (the prerequisite for the inbound-write pipeline); a Read tag
+/// stays read-only. This flag is derived identically on the artifact-decode side
+/// (DeploymentArtifact.BuildEquipmentTagPlans) for byte-parity.
///
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);
///
/// One Equipment-namespace VirtualTag from a 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
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index 8dbf47ca..c92682a3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -56,8 +56,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
/// The parent folder node identifier.
/// The display name for the variable.
/// The OPC UA data type.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
- => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
+ /// When true the node is created read/write; otherwise read-only.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
+ => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable);
/// Rebuilds the entire OPC UA address space.
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
index 50ae20d0..8b3f89ad 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
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 tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? 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 (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
@@ -432,7 +444,8 @@ public static class DeploymentArtifact
FolderPath: folder ?? string.Empty,
Name: name!,
DataType: dataType ?? "BaseDataType",
- FullName: ExtractTagFullName(tagConfig)));
+ FullName: ExtractTagFullName(tagConfig),
+ Writable: writable));
}
result.Sort((a, b) =>
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
index 9600d9a3..bd35324d 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -93,7 +93,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
///
- 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}");
///
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
index 2a51c508..61885d76 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
@@ -143,7 +143,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
{
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
/// The node ID of the parent folder, or null for root.
/// The display name of the variable.
/// The OPC UA built-in type name.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
+ /// Whether the node is created read/write.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// Rebuilds the address space (stub implementation for testing).
public void RebuildAddressSpace() { }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
index 9a2e5f36..7dd2ad81 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -131,14 +131,15 @@ public sealed class Phase7ApplierTests
{
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);
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).
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
}
@@ -157,14 +158,15 @@ public sealed class Phase7ApplierTests
{
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);
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).
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
}
@@ -183,16 +185,16 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
- new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
- new EquipmentTagPlan("tag-b", "eq-2", "drv-2", 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", Writable: false),
},
};
applier.MaterialiseEquipmentTags(composition);
sink.VariableCalls.Count.ShouldBe(2);
- sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float"));
- sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float"));
+ sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false));
+ sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false));
}
/// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
@@ -218,7 +220,8 @@ public sealed class Phase7ApplierTests
applier.MaterialiseEquipmentVirtualTags(composition);
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.
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm"));
}
@@ -240,8 +243,8 @@ public sealed class Phase7ApplierTests
{
EquipmentTags = new[]
{
- new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
- new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
+ 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", Writable: false),
},
EquipmentVirtualTags = new[]
{
@@ -286,8 +289,8 @@ public sealed class Phase7ApplierTests
sink.FolderCalls.ShouldBeEmpty();
sink.VariableCalls.Count.ShouldBe(2);
- sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
- sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "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", false));
}
/// T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
@@ -335,7 +338,7 @@ public sealed class Phase7ApplierTests
{
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
/// Gets the queue of folder creation calls.
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// Gets the queue of variable creation calls.
- 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();
/// Gets the queue of alarm-condition materialise calls.
public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
/// Gets the number of rebuild calls made on this sink.
@@ -405,7 +408,7 @@ public sealed class Phase7ApplierTests
/// Gets the list of recorded folder creation calls.
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// Gets the list of recorded variable creation calls.
- 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();
/// Gets the list of recorded alarm-condition materialise calls.
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
@@ -440,8 +443,9 @@ public sealed class Phase7ApplierTests
/// The parent folder node ID, if any.
/// The display name for the variable.
/// The OPC UA built-in type name.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
- => VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
+ /// Whether the node is created read/write.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
+ => VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// Records a rebuild address space call.
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
@@ -485,7 +489,8 @@ public sealed class Phase7ApplierTests
/// The parent folder node ID, if any.
/// The display name for the variable.
/// The OPC UA built-in type name.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
+ /// Whether the node is created read/write.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// No-op rebuild address space call.
public void RebuildAddressSpace() { }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs
index 886a7de1..8890d636 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7PlannerTests.cs
@@ -43,7 +43,7 @@ public sealed class Phase7PlannerTests
{
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),
},
};
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
index 846cd3fa..76f431e1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
@@ -62,6 +62,80 @@ public sealed class DeploymentArtifactAliasParityTests
tag.DataType.ShouldBe("Int32");
tag.FolderPath.ShouldBe(string.Empty);
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
+ // No AccessLevel in the blob → defaults to non-writable (read-only node).
+ tag.Writable.ShouldBeFalse();
+ }
+
+ /// The artifact decoder reads AccessLevel into EquipmentTagPlan.Writable:
+ /// 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.
+ [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);
+ }
+
+ /// 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.
+ [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);
}
/// 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.DataType.ShouldBe(x.DataType);
d.FullName.ShouldBe(x.FullName);
+ d.Writable.ShouldBe(x.Writable);
}
var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy");
@@ -246,6 +321,15 @@ public sealed class DeploymentArtifactAliasParityTests
galaxyPlan.EquipmentId.ShouldBe("eq-galaxy");
galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy");
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();
}
/// The full Pascal-case snapshot a EF entity serialises to in the
@@ -258,6 +342,9 @@ public sealed class DeploymentArtifactAliasParityTests
t.Name,
t.FolderPath,
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,
};
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
index f46666ca..d58d17f2 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
@@ -217,7 +217,8 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// The parent folder node identifier.
/// The display name for the variable.
/// The OPC UA built-in type name.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
+ /// Whether the node is created read/write.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// Rebuilds address space (recorded via span).
public void RebuildAddressSpace() { /* recorded via span */ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
index c399e456..6e3e85ba 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
@@ -287,7 +287,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// The parent folder node ID, or null if this is a root variable.
/// The display name of the variable.
/// The OPC UA built-in type name.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
+ /// Whether the node is created read/write.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable)
=> Calls.Enqueue($"EV:{variableNodeId}");
/// Records a rebuild address space call.
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
index c7c2a2a4..34d98e73 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
@@ -208,7 +208,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// The parent folder node identifier, or null for root.
/// The display name of the variable.
/// The OPC UA built-in type name.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
+ /// Whether the node is created read/write.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable) { }
/// Records a rebuild call.
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);