Add security classification, alarm detection, historical data access, and primitive grouping

Wire Galaxy security_classification to OPC UA AccessLevel (ReadOnly for SecuredWrite/VerifiedWrite/ViewOnly).
Use deployed package chain for attribute queries to exclude undeployed attributes.
Group primitive attributes under their parent variable node (merged Variable+Object).
Add is_historized and is_alarm detection via HistoryExtension/AlarmExtension primitives.
Implement OPC UA HistoryRead backed by Wonderware Historian Runtime database.
Implement AlarmConditionState nodes driven by InAlarm with condition refresh support.
Add historyread and alarms CLI commands for testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-26 11:32:33 -04:00
parent bb0a89b2a1
commit 415e62c585
30 changed files with 2734 additions and 217 deletions

View File

@@ -22,6 +22,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
info.AttributeName.ShouldBe("");
info.FullTagReference.ShouldBe("");
info.DataTypeName.ShouldBe("");
info.SecurityClassification.ShouldBe(1);
info.IsHistorized.ShouldBeFalse();
info.IsAlarm.ShouldBeFalse();
}
/// <summary>

View File

@@ -0,0 +1,37 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Domain
{
public class SecurityClassificationMapperTests
{
[Theory]
[InlineData(0, true)] // FreeAccess
[InlineData(1, true)] // Operate
[InlineData(4, true)] // Tune
[InlineData(5, true)] // Configure
public void Writable_SecurityLevels(int classification, bool expected)
{
SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected);
}
[Theory]
[InlineData(2, false)] // SecuredWrite
[InlineData(3, false)] // VerifiedWrite
[InlineData(6, false)] // ViewOnly
public void ReadOnly_SecurityLevels(int classification, bool expected)
{
SecurityClassificationMapper.IsWritable(classification).ShouldBe(expected);
}
[Theory]
[InlineData(-1)]
[InlineData(7)]
[InlineData(99)]
public void Unknown_Values_DefaultToWritable(int classification)
{
SecurityClassificationMapper.IsWritable(classification).ShouldBeTrue();
}
}
}

View File

@@ -0,0 +1,40 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Historian;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
{
public class HistorianQualityMappingTests
{
[Fact]
public void Quality0_MapsToGood()
{
HistorianDataSource.MapQuality(0).ShouldBe(StatusCodes.Good);
}
[Fact]
public void Quality1_MapsToBad()
{
HistorianDataSource.MapQuality(1).ShouldBe(StatusCodes.Bad);
}
[Theory]
[InlineData(128)]
[InlineData(133)]
[InlineData(192)]
public void QualityAbove128_MapsToUncertain(byte quality)
{
HistorianDataSource.MapQuality(quality).ShouldBe(StatusCodes.Uncertain);
}
[Theory]
[InlineData(2)]
[InlineData(50)]
[InlineData(127)]
public void OtherBadQualities_MapToBad(byte quality)
{
HistorianDataSource.MapQuality(quality).ShouldBe(StatusCodes.Bad);
}
}
}

View File

@@ -0,0 +1,111 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
{
public class AccessLevelTests
{
private static FakeGalaxyRepository CreateRepoWithSecurityLevels()
{
return new FakeGalaxyRepository
{
Hierarchy = new List<GalaxyObjectInfo>
{
new GalaxyObjectInfo { GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false }
},
Attributes = new List<GalaxyAttributeInfo>
{
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "FreeAttr", FullTagReference = "TestObj.FreeAttr", MxDataType = 5, SecurityClassification = 0 },
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "OperateAttr", FullTagReference = "TestObj.OperateAttr", MxDataType = 5, SecurityClassification = 1 },
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "SecuredAttr", FullTagReference = "TestObj.SecuredAttr", MxDataType = 5, SecurityClassification = 2 },
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "VerifiedAttr", FullTagReference = "TestObj.VerifiedAttr", MxDataType = 5, SecurityClassification = 3 },
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "TuneAttr", FullTagReference = "TestObj.TuneAttr", MxDataType = 5, SecurityClassification = 4 },
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "ConfigAttr", FullTagReference = "TestObj.ConfigAttr", MxDataType = 5, SecurityClassification = 5 },
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "ViewOnlyAttr", FullTagReference = "TestObj.ViewOnlyAttr", MxDataType = 5, SecurityClassification = 6 },
}
};
}
[Fact]
public async Task ReadWriteAttribute_HasCurrentReadOrWrite_AccessLevel()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
foreach (var attrName in new[] { "FreeAttr", "OperateAttr", "TuneAttr", "ConfigAttr" })
{
var nodeId = client.MakeNodeId($"TestObj.{attrName}");
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
((byte)accessLevel.Value).ShouldBe(AccessLevels.CurrentReadOrWrite,
$"{attrName} should be ReadWrite");
}
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task ReadOnlyAttribute_HasCurrentRead_AccessLevel()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
foreach (var attrName in new[] { "SecuredAttr", "VerifiedAttr", "ViewOnlyAttr" })
{
var nodeId = client.MakeNodeId($"TestObj.{attrName}");
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
((byte)accessLevel.Value).ShouldBe(AccessLevels.CurrentRead,
$"{attrName} should be ReadOnly");
}
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task Write_ToReadOnlyAttribute_IsRejected()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepoWithSecurityLevels());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var nodeId = client.MakeNodeId("TestObj.ViewOnlyAttr");
var result = client.Write(nodeId, "test");
StatusCode.IsBad(result).ShouldBeTrue("Write to ReadOnly attribute should be rejected");
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task Write_ToReadWriteAttribute_Succeeds()
{
var mxClient = new FakeMxAccessClient();
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(mxClient: mxClient, repo: CreateRepoWithSecurityLevels());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var nodeId = client.MakeNodeId("TestObj.OperateAttr");
var result = client.Write(nodeId, "test");
StatusCode.IsGood(result).ShouldBeTrue("Write to ReadWrite attribute should succeed");
}
finally { await fixture.DisposeAsync(); }
}
}
}

View File

@@ -310,5 +310,74 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
await fixture.DisposeAsync();
}
}
/// <summary>
/// Confirms that transferred monitored items recreate MXAccess subscriptions when the service has no local subscription state.
/// </summary>
[Fact]
public async Task TransferSubscriptions_RestoresMxAccessSubscriptionState_WhenLocalStateIsMissing()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
await fixture.InitializeAsync();
try
{
var nodeManager = fixture.Service.NodeManagerInstance!;
var mxClient = fixture.MxAccessClient!;
nodeManager.RestoreTransferredSubscriptions(new[]
{
"TestMachine_001.MachineID",
"TestMachine_001.MachineID"
});
await Task.Delay(100);
mxClient.ActiveSubscriptionCount.ShouldBe(1);
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
mxClient.ActiveSubscriptionCount.ShouldBe(1);
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
mxClient.ActiveSubscriptionCount.ShouldBe(0);
}
finally
{
await fixture.DisposeAsync();
}
}
/// <summary>
/// Confirms that transferring monitored items does not double-count subscriptions already tracked in memory.
/// </summary>
[Fact]
public async Task TransferSubscriptions_DoesNotDoubleCount_WhenSubscriptionAlreadyTracked()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
await fixture.InitializeAsync();
try
{
var nodeManager = fixture.Service.NodeManagerInstance!;
var mxClient = fixture.MxAccessClient!;
nodeManager.SubscribeTag("TestMachine_001.MachineID");
mxClient.ActiveSubscriptionCount.ShouldBe(1);
nodeManager.RestoreTransferredSubscriptions(new[]
{
"TestMachine_001.MachineID"
});
await Task.Delay(100);
mxClient.ActiveSubscriptionCount.ShouldBe(1);
nodeManager.UnsubscribeTag("TestMachine_001.MachineID");
mxClient.ActiveSubscriptionCount.ShouldBe(0);
}
finally
{
await fixture.DisposeAsync();
}
}
}
}

View File

@@ -0,0 +1,74 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
{
public class HistorizingFlagTests
{
private static FakeGalaxyRepository CreateRepo()
{
return new FakeGalaxyRepository
{
Hierarchy = new List<GalaxyObjectInfo>
{
new GalaxyObjectInfo { GobjectId = 1, TagName = "TestObj", BrowseName = "TestObj", ParentGobjectId = 0, IsArea = false }
},
Attributes = new List<GalaxyAttributeInfo>
{
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "HistorizedAttr", FullTagReference = "TestObj.HistorizedAttr", MxDataType = 2, IsHistorized = true },
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "NormalAttr", FullTagReference = "TestObj.NormalAttr", MxDataType = 5, IsHistorized = false },
new GalaxyAttributeInfo { GobjectId = 1, TagName = "TestObj", AttributeName = "AlarmAttr", FullTagReference = "TestObj.AlarmAttr", MxDataType = 1, IsAlarm = true },
}
};
}
[Fact]
public async Task HistorizedAttribute_HasHistorizingTrue_AndHistoryReadAccess()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepo());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var nodeId = client.MakeNodeId("TestObj.HistorizedAttr");
var historizing = client.ReadAttribute(nodeId, Attributes.Historizing);
((bool)historizing.Value).ShouldBeTrue();
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
var level = (byte)accessLevel.Value;
(level & AccessLevels.HistoryRead).ShouldBe(AccessLevels.HistoryRead,
"HistoryRead bit should be set");
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task NormalAttribute_HasHistorizingFalse_AndNoHistoryReadAccess()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(repo: CreateRepo());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var nodeId = client.MakeNodeId("TestObj.NormalAttr");
var historizing = client.ReadAttribute(nodeId, Attributes.Historizing);
((bool)historizing.Value).ShouldBeFalse();
var accessLevel = client.ReadAttribute(nodeId, Attributes.AccessLevel);
var level = (byte)accessLevel.Value;
(level & AccessLevels.HistoryRead).ShouldBe((byte)0,
"HistoryRead bit should not be set");
}
finally { await fixture.DisposeAsync(); }
}
}
}