Use bracketless OPC UA node IDs for arrays
This commit is contained in:
51
service_info.md
Normal file
51
service_info.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -130,6 +130,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
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>
|
||||
/// Write a node's value, optionally using an OPC UA index range for array element writes.
|
||||
/// Returns the server status code for the write.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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[]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user