diff --git a/service_info.md b/service_info.md
new file mode 100644
index 0000000..6cad15a
--- /dev/null
+++ b/service_info.md
@@ -0,0 +1,51 @@
+# Service Update Summary
+
+Updated service instance: `C:\publish\lmxopcua\instance1`
+
+Update time: `2026-03-25 12:54-12:55 America/New_York`
+
+Backup created before deploy: `C:\publish\lmxopcua\backups\20260325-125444`
+
+Configuration preserved:
+- `C:\publish\lmxopcua\instance1\appsettings.json` was not overwritten.
+
+Deployed binary:
+- `C:\publish\lmxopcua\instance1\ZB.MOM.WW.LmxOpcUa.Host.exe`
+- Last write time: `2026-03-25 12:53:58`
+- Size: `143360`
+
+Windows service:
+- Name: `LmxOpcUa`
+- Display name: `LMX OPC UA Server`
+- Account: `LocalSystem`
+- Status after update: `Running`
+- Process ID after restart: `29236`
+
+Restart evidence:
+- Service log file: `C:\publish\lmxopcua\instance1\logs\lmxopcua-20260325_004.log`
+- Last startup line: `2026-03-25 12:55:08.619 -04:00 [INF] The LmxOpcUa service was started.`
+
+## CLI Verification
+
+Endpoint from deployed config:
+- `opc.tcp://localhost:4840/LmxOpcUa`
+
+CLI used:
+- `C:\Users\dohertj2\Desktop\lmxopcua\tools\opcuacli-dotnet\bin\Debug\net10.0\opcuacli-dotnet.exe`
+
+Commands run:
+
+```powershell
+opcuacli-dotnet.exe connect -u opc.tcp://localhost:4840/LmxOpcUa
+opcuacli-dotnet.exe read -u opc.tcp://localhost:4840/LmxOpcUa -n 'ns=1;s=MESReceiver_001.MoveInPartNumbers'
+opcuacli-dotnet.exe read -u opc.tcp://localhost:4840/LmxOpcUa -n 'ns=1;s=MESReceiver_001.MoveInPartNumbers[]'
+```
+
+Observed results:
+- `connect`: succeeded, server reported as `LmxOpcUa`.
+- `read ns=1;s=MESReceiver_001.MoveInPartNumbers`: succeeded with good status `0x00000000`.
+- `read ns=1;s=MESReceiver_001.MoveInPartNumbers[]`: failed with `BadNodeIdUnknown` (`0x80340000`).
+
+## Notes
+
+The service deployment and restart succeeded. The live CLI checks confirm the endpoint is reachable and that the array node identifier has changed to the bracketless form. The array value on the live service still prints as blank even though the status is good, so if this environment should have populated `MoveInPartNumbers`, the runtime data path still needs follow-up investigation.
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
index 53978c5..e12f442 100644
--- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
+++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/AddressSpaceBuilder.cs
@@ -178,12 +178,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
ArrayDimension = attr.ArrayDimension
});
- model.NodeIdToTagReference[attr.FullTagReference] = attr.FullTagReference;
+ model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
model.VariableCount++;
}
}
return node;
}
+
+ private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
+ {
+ if (!attr.IsArray)
+ return attr.FullTagReference;
+
+ return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
+ ? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
+ : attr.FullTagReference;
+ }
}
}
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
index 535662f..8d14e75 100644
--- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
+++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs
@@ -256,7 +256,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var variable = CreateVariable(parent, attr.AttributeName, attr.AttributeName, new NodeId(opcUaDataTypeId),
attr.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar);
- var nodeIdString = attr.FullTagReference;
+ var nodeIdString = GetNodeIdentifier(attr);
variable.NodeId = new NodeId(nodeIdString, NamespaceIndex);
if (attr.IsArray && attr.ArrayDimension.HasValue)
@@ -275,6 +275,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
VariableNodeCount++;
}
+ private static string GetNodeIdentifier(GalaxyAttributeInfo attr)
+ {
+ if (!attr.IsArray)
+ return attr.FullTagReference;
+
+ return attr.FullTagReference.EndsWith("[]", StringComparison.Ordinal)
+ ? attr.FullTagReference.Substring(0, attr.FullTagReference.Length - 2)
+ : attr.FullTagReference;
+ }
+
private FolderState CreateFolder(NodeState? parent, string path, string name)
{
var folder = new FolderState(parent)
@@ -348,6 +358,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
for (int i = 0; i < nodesToRead.Count; i++)
{
+ if (nodesToRead[i].AttributeId != Attributes.Value)
+ continue;
+
var nodeId = nodesToRead[i].NodeId;
if (nodeId.NamespaceIndex != NamespaceIndex) continue;
@@ -379,6 +392,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
for (int i = 0; i < nodesToWrite.Count; i++)
{
+ if (nodesToWrite[i].AttributeId != Attributes.Value)
+ continue;
+
var nodeId = nodesToWrite[i].NodeId;
if (nodeId.NamespaceIndex != NamespaceIndex) continue;
@@ -404,7 +420,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
var success = _mxAccessClient.WriteAsync(tagRef, value).GetAwaiter().GetResult();
- errors[i] = success ? ServiceResult.Good : new ServiceResult(StatusCodes.BadInternalError);
+ if (success)
+ {
+ PublishLocalWrite(tagRef, value);
+ errors[i] = ServiceResult.Good;
+ }
+ else
+ {
+ errors[i] = new ServiceResult(StatusCodes.BadInternalError);
+ }
}
catch (Exception ex)
{
@@ -462,6 +486,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
return Convert.ChangeType(value, elementType);
}
+ private void PublishLocalWrite(string tagRef, object? value)
+ {
+ if (!_tagToVariableNode.TryGetValue(tagRef, out var variable))
+ return;
+
+ var dataValue = DataValueConverter.FromVtq(Vtq.Good(value));
+ variable.Value = dataValue.Value;
+ variable.StatusCode = dataValue.StatusCode;
+ variable.Timestamp = dataValue.SourceTimestamp;
+ variable.ClearChangeMasks(SystemContext, false);
+ }
+
#endregion
#region Subscription Delivery
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs
index 06ade70..c873b33 100644
--- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs
@@ -130,6 +130,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return Session.ReadValue(nodeId);
}
+ ///
+ /// Read a specific OPC UA attribute from a node.
+ ///
+ /// The node whose attribute should be read.
+ /// The OPC UA attribute identifier to read.
+ /// The attribute value returned by the server.
+ public DataValue ReadAttribute(NodeId nodeId, uint attributeId)
+ {
+ var nodesToRead = new ReadValueIdCollection
+ {
+ new ReadValueId
+ {
+ NodeId = nodeId,
+ AttributeId = attributeId
+ }
+ };
+
+ Session.Read(
+ null,
+ 0,
+ TimestampsToReturn.Neither,
+ nodesToRead,
+ out var results,
+ out _);
+
+ return results[0];
+ }
+
///
/// Write a node's value, optionally using an OPC UA index range for array element writes.
/// Returns the server status code for the write.
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs
index 920ebfa..4c88b8a 100644
--- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs
@@ -1,6 +1,8 @@
+using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
using Opc.Ua;
+using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
@@ -30,7 +32,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
await client.ConnectAsync(fixture.EndpointUrl);
// Browse path: TestMachine_001 -> MESReceiver -> MoveInPartNumbers
- var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers[]");
+ var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
var before = client.Read(nodeId).Value as string[];
before.ShouldNotBeNull();
@@ -52,5 +54,87 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
await fixture.DisposeAsync();
}
}
+
+ ///
+ /// Confirms that array nodes use bracketless OPC UA node identifiers while still exposing one-dimensional array metadata.
+ ///
+ [Fact]
+ public async Task ArrayNode_UsesBracketlessNodeId_AndPublishesArrayDimensions()
+ {
+ var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
+ fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(
+ Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray());
+
+ await fixture.InitializeAsync();
+ try
+ {
+ using var client = new OpcUaTestClient();
+ await client.ConnectAsync(fixture.EndpointUrl);
+
+ var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
+
+ var value = client.Read(nodeId).Value as string[];
+ value.ShouldNotBeNull();
+ value.Length.ShouldBe(50);
+
+ var valueRank = client.ReadAttribute(nodeId, Attributes.ValueRank).Value;
+ valueRank.ShouldBe(ValueRanks.OneDimension);
+
+ var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[];
+ dimensions.ShouldNotBeNull();
+ dimensions.ShouldBe(new uint[] { 50 });
+ }
+ finally
+ {
+ await fixture.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Confirms that an indexed write also updates the published OPC UA value seen by subscribed clients.
+ ///
+ [Fact]
+ public async Task Write_SingleArrayElement_PublishesUpdatedArrayToSubscribers()
+ {
+ var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
+ fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(
+ Enumerable.Range(0, 50).Select(i => $"PART-{i:00}").ToArray());
+
+ await fixture.InitializeAsync();
+ try
+ {
+ using var client = new OpcUaTestClient();
+ await client.ConnectAsync(fixture.EndpointUrl);
+
+ var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers");
+ var notifications = new ConcurrentBag();
+ var (sub, item) = await client.SubscribeAsync(nodeId, intervalMs: 100);
+ item.Notification += (_, e) =>
+ {
+ if (e.NotificationValue is MonitoredItemNotification notification)
+ notifications.Add(notification);
+ };
+
+ await Task.Delay(500);
+
+ var status = client.Write(nodeId, new[] { "UPDATED-PART" }, indexRange: "1");
+ StatusCode.IsGood(status).ShouldBe(true);
+
+ await Task.Delay(1000);
+
+ notifications.Any(n =>
+ n.Value.Value is string[] values &&
+ values.Length == 50 &&
+ values[0] == "PART-00" &&
+ values[1] == "UPDATED-PART" &&
+ values[2] == "PART-02").ShouldBe(true);
+
+ await sub.DeleteAsync(true);
+ }
+ finally
+ {
+ await fixture.DisposeAsync();
+ }
+ }
}
}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
index c087a7a..e324172 100644
--- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/OpcUa/LmxNodeManagerBuildTests.cs
@@ -61,7 +61,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true);
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").ShouldBe(true);
- model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems[]").ShouldBe(true);
+ model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems").ShouldBe(true);
}
///
@@ -73,7 +73,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
var (hierarchy, attributes) = CreateTestData();
var model = AddressSpaceBuilder.Build(hierarchy, attributes);
- model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems[]").ShouldBe(true);
+ model.NodeIdToTagReference.ContainsKey("TestMachine_001.BatchItems").ShouldBe(true);
+ model.NodeIdToTagReference["TestMachine_001.BatchItems"].ShouldBe("TestMachine_001.BatchItems[]");
}
///