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);