Use bracketless OPC UA node IDs for arrays

This commit is contained in:
Joseph Doherty
2026-03-25 12:57:05 -04:00
parent 4833765606
commit ed42b33512
6 changed files with 216 additions and 6 deletions

51
service_info.md Normal file
View File

@@ -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.

View File

@@ -178,12 +178,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
ArrayDimension = attr.ArrayDimension ArrayDimension = attr.ArrayDimension
}); });
model.NodeIdToTagReference[attr.FullTagReference] = attr.FullTagReference; model.NodeIdToTagReference[GetNodeIdentifier(attr)] = attr.FullTagReference;
model.VariableCount++; model.VariableCount++;
} }
} }
return node; 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;
}
} }
} }

View File

@@ -256,7 +256,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var variable = CreateVariable(parent, attr.AttributeName, attr.AttributeName, new NodeId(opcUaDataTypeId), var variable = CreateVariable(parent, attr.AttributeName, attr.AttributeName, new NodeId(opcUaDataTypeId),
attr.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar); attr.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar);
var nodeIdString = attr.FullTagReference; var nodeIdString = GetNodeIdentifier(attr);
variable.NodeId = new NodeId(nodeIdString, NamespaceIndex); variable.NodeId = new NodeId(nodeIdString, NamespaceIndex);
if (attr.IsArray && attr.ArrayDimension.HasValue) if (attr.IsArray && attr.ArrayDimension.HasValue)
@@ -275,6 +275,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
VariableNodeCount++; 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) private FolderState CreateFolder(NodeState? parent, string path, string name)
{ {
var folder = new FolderState(parent) var folder = new FolderState(parent)
@@ -348,6 +358,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
for (int i = 0; i < nodesToRead.Count; i++) for (int i = 0; i < nodesToRead.Count; i++)
{ {
if (nodesToRead[i].AttributeId != Attributes.Value)
continue;
var nodeId = nodesToRead[i].NodeId; var nodeId = nodesToRead[i].NodeId;
if (nodeId.NamespaceIndex != NamespaceIndex) continue; if (nodeId.NamespaceIndex != NamespaceIndex) continue;
@@ -379,6 +392,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
for (int i = 0; i < nodesToWrite.Count; i++) for (int i = 0; i < nodesToWrite.Count; i++)
{ {
if (nodesToWrite[i].AttributeId != Attributes.Value)
continue;
var nodeId = nodesToWrite[i].NodeId; var nodeId = nodesToWrite[i].NodeId;
if (nodeId.NamespaceIndex != NamespaceIndex) continue; if (nodeId.NamespaceIndex != NamespaceIndex) continue;
@@ -404,7 +420,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
} }
var success = _mxAccessClient.WriteAsync(tagRef, value).GetAwaiter().GetResult(); 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) catch (Exception ex)
{ {
@@ -462,6 +486,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
return Convert.ChangeType(value, elementType); 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 #endregion
#region Subscription Delivery #region Subscription Delivery

View File

@@ -130,6 +130,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
return Session.ReadValue(nodeId); return Session.ReadValue(nodeId);
} }
/// <summary>
/// Read a specific OPC UA attribute from a node.
/// </summary>
/// <param name="nodeId">The node whose attribute should be read.</param>
/// <param name="attributeId">The OPC UA attribute identifier to read.</param>
/// <returns>The attribute value returned by the server.</returns>
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];
}
/// <summary> /// <summary>
/// Write a node's value, optionally using an OPC UA index range for array element writes. /// Write a node's value, optionally using an OPC UA index range for array element writes.
/// Returns the server status code for the write. /// Returns the server status code for the write.

View File

@@ -1,6 +1,8 @@
using System.Collections.Concurrent;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Client;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Domain; using ZB.MOM.WW.LmxOpcUa.Host.Domain;
@@ -30,7 +32,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
await client.ConnectAsync(fixture.EndpointUrl); await client.ConnectAsync(fixture.EndpointUrl);
// Browse path: TestMachine_001 -> MESReceiver -> MoveInPartNumbers // 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[]; var before = client.Read(nodeId).Value as string[];
before.ShouldNotBeNull(); before.ShouldNotBeNull();
@@ -52,5 +54,87 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
await fixture.DisposeAsync(); await fixture.DisposeAsync();
} }
} }
/// <summary>
/// Confirms that array nodes use bracketless OPC UA node identifiers while still exposing one-dimensional array metadata.
/// </summary>
[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();
}
}
/// <summary>
/// Confirms that an indexed write also updates the published OPC UA value seen by subscribed clients.
/// </summary>
[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<MonitoredItemNotification>();
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();
}
}
} }
} }

View File

@@ -61,7 +61,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true); model.NodeIdToTagReference.ContainsKey("TestMachine_001.MachineID").ShouldBe(true);
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true); model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.DownloadPath").ShouldBe(true);
model.NodeIdToTagReference.ContainsKey("DelmiaReceiver_001.JobStepNumber").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);
} }
/// <summary> /// <summary>
@@ -73,7 +73,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.OpcUa
var (hierarchy, attributes) = CreateTestData(); var (hierarchy, attributes) = CreateTestData();
var model = AddressSpaceBuilder.Build(hierarchy, attributes); 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[]");
} }
/// <summary> /// <summary>