2b0eed1a8f
- ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER, ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>] (<r> = n | lo..hi; negative numbers allowed). An ALARM: address and the cross-driver alarm object only work together — the factory refuses either alone, so editor, save path, CSV and deploy inspector agree. Data type fixed Boolean; never a value (ReadAsync BadNotSupported), never in a data subscription. - HeidenhainDriver implements IAlarmSource: one condition per ALARM: tag at its RawPath, driven by the SAME allowlisted R_RI 27/28 walk on the control's single loop, on the new Polling.AlarmMs cadence (default 2000 ms, only for controls carrying an ALARM: tag). FOCAS #647 transition rules (Raise / Retrigger / Clear), identity = class+group+number+text, deterministic display order, told-record on the driver instance, recorded only while a handler listens. A failed walk (refusal, overflow, comms loss) is quality-only, never a clear. - Acknowledge is LOCAL-ONLY: AcknowledgeAsync is a no-op success (FOCAS/MTConnect posture); Lsv2ReadOnlyPolicy is unchanged — no ack or write request exists. - Opt-in per tag, no driver flag (FOCAS #647 / MTConnect #626 precedent); Alarms/* arrays unchanged. - IRediscoverable: a reconnect whose R_VR identity (model / NC / PLC / options) differs from the previous session raises OnRediscoveryNeeded once (advisory re-browse chip). No extra traffic. - AdminUI: Heidenhain joins NativeAlarmModel's alarm drivers; the typed editor fixes ALARM: tags at Boolean and seeds/drops the alarm object with the selector; driver form gains "Alarm conditions (ms)". (HeidenhainDriverFormModel also carries the #830 credential-notice change.) Tests: HeidenhainAlarmTests (grammar, pairing, rules, simulator e2e with the captured iTNC 530 alarm record, local-only ack = zero bytes sent, quality-only outage/refusal, rediscovery), Runtime.Tests HeidenhainNativeAlarmEndToEndTests (real DriverHostActor condition), a DraftValidator case, and the NativeAlarmDriverSetGuard now scans the Heidenhain driver.
165 lines
7.6 KiB
C#
165 lines
7.6 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Akka.TestKit;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#831 — a Heidenhain controller alarm reaches its Part 9 condition END TO END: the REAL
|
|
/// <see cref="HeidenhainDriver"/> against the in-process LSV2 simulator (replaying the captured iTNC 530 alarm
|
|
/// record), fed the raw tags exactly as the deployment artifact delivers them, raising into the REAL
|
|
/// <see cref="DriverHostActor"/>, whose projector must turn it into an Active and then an inactive condition at
|
|
/// the tag's RawPath (the FOCAS #647 lesson: a test that stops at the driver's event cannot see a routing miss).
|
|
/// </summary>
|
|
public sealed class HeidenhainNativeAlarmEndToEndTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("heidenhain-alarm-e2e");
|
|
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('a', 64));
|
|
private const string DriverId = "drv-hh";
|
|
private const string ExpectedRawPath = "Plant/Hh/m1/cnc_alarms";
|
|
|
|
/// <summary>Java fixture <c>hermle49.27</c>: class 7, group 4, number -1, "Run test of cutout channels".</summary>
|
|
private static readonly byte[] Golden =
|
|
Convert.FromHexString("00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300");
|
|
|
|
[Fact]
|
|
public async Task A_controller_alarm_activates_then_clears_the_condition_at_the_RawPath()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var blob = BuildArtifact();
|
|
var (host, publish) = SpawnHostAndApply(db, Seed(db, blob));
|
|
var rawTags = RawTagsFor(blob);
|
|
rawTags.Select(t => t.RawPath).ShouldBe([ExpectedRawPath]);
|
|
|
|
var control = new SimulatedControl();
|
|
var active = true;
|
|
control.Runtime[27] = () => active ? SimReply.Ok("S_RI", Golden) : SimReply.Refuse(57);
|
|
control.Runtime[28] = () => SimReply.Refuse(57);
|
|
await using var sim = Lsv2Simulator.WithControl(control);
|
|
|
|
await using var driver = new HeidenhainDriver(new HeidenhainDriverOptions
|
|
{
|
|
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = sim.Port, DeviceName = "m1" }],
|
|
RawTags = rawTags,
|
|
TimeoutMs = 500,
|
|
Polling = new HeidenhainPollingOptions { AlarmMs = 100 },
|
|
}, DriverId);
|
|
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
#pragma warning disable OTOPCUA0001 // routing under test, not resilience wrapping (the FOCAS e2e precedent)
|
|
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
|
|
#pragma warning restore OTOPCUA0001
|
|
|
|
var raised = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5),
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
raised.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
|
raised.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
|
raised.State.Active.ShouldBeTrue();
|
|
raised.State.Acknowledged.ShouldBeFalse();
|
|
raised.State.Severity.ShouldBe((ushort)800);
|
|
raised.State.Message.ShouldStartWith("-1 Run test of cutout channels");
|
|
|
|
active = false;
|
|
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(u => !u.State.Active, TimeSpan.FromSeconds(5),
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
cleared.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
|
|
|
control.Violations.ShouldBeEmpty();
|
|
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_RI" });
|
|
}
|
|
|
|
private static List<RawTagEntry> RawTagsFor(byte[] blob)
|
|
{
|
|
using var artifact = DeploymentArtifact.Open(blob);
|
|
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == DriverId);
|
|
using var doc = JsonDocument.Parse(spec.DriverConfig);
|
|
return JsonSerializer.Deserialize<List<RawTagEntry>>(
|
|
doc.RootElement.GetProperty("RawTags").GetRawText(),
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
|
}
|
|
|
|
private static byte[] BuildArtifact() => JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
|
DriverInstances = new[]
|
|
{
|
|
// Enabled=false: the host spawns no driver — the test drives its own instance against the simulator.
|
|
new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "Hh", DriverType = "Heidenhain", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
|
|
},
|
|
Devices = new[]
|
|
{
|
|
new { DeviceId = "dev-m1", DriverInstanceId = DriverId, Name = "m1", DeviceConfig = JsonSerializer.Serialize(new { Host = "127.0.0.1" }) },
|
|
},
|
|
TagGroups = Array.Empty<object>(),
|
|
Tags = new[]
|
|
{
|
|
new
|
|
{
|
|
TagId = "tag-alarms",
|
|
DeviceId = "dev-m1",
|
|
TagGroupId = (string?)null,
|
|
Name = "cnc_alarms",
|
|
DataType = "Boolean",
|
|
AccessLevel = 0,
|
|
TagConfig = JsonSerializer.Serialize(new
|
|
{
|
|
address = "ALARM:*",
|
|
dataType = "Boolean",
|
|
alarm = new { alarmType = "AlarmCondition", severity = 800 },
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
|
|
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob)
|
|
{
|
|
var id = DeploymentId.NewId();
|
|
using var ctx = db.CreateDbContext();
|
|
ctx.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = id.Value,
|
|
RevisionHash = Rev.Value,
|
|
Status = DeploymentStatus.Sealed,
|
|
CreatedBy = "test",
|
|
SealedAtUtc = DateTime.UtcNow,
|
|
ArtifactBlob = blob,
|
|
});
|
|
ctx.SaveChanges();
|
|
return id;
|
|
}
|
|
|
|
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
|
{
|
|
var coordinator = CreateTestProbe();
|
|
var publish = this.CreateCleanOutcomePublishProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
localRoles: new HashSet<string> { "driver" },
|
|
dependencyMux: CreateTestProbe().Ref,
|
|
opcUaPublishActor: publish.Ref,
|
|
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
|
virtualTagHostOverride: CreateTestProbe().Ref));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
|
return (actor, publish);
|
|
}
|
|
}
|