feat(heidenhain): native read-only LSV2 driver + in-process simulator (#803)
Native LSV2 TCP transport (default port 19000; no Java/Python/vendor SDK), ported from the ignitionoee v0.20.3 reference driver: - Lsv2ReadOnlyPolicy: payload-aware outbound allowlist (A_LG INSPECT/DNC/DATA, R_VR, R_PR, R_RI 22-28/30-34/51, bounded R_MB, R_DP), applied in Lsv2Codec.Frame before any byte is written; no public raw-command API. - Lsv2Client: serialized transactions, one monotonic deadline per transaction, EOF/oversize/ unexpected/trickle close the client; Close never waits on the gate. - Lsv2Data / HeidenhainBindingCodec: reference decoders, R_PR byte-capacity + element-index rules, next-area cap, DATA Boolean 00/01/FF, strict non-truncating coercion. - HeidenhainSession: per-batch wire cache, refusal suppression, provisional DNC denial retry, alarm cursor (57 = end, overflow = BadOutOfRange), capability strings. - HeidenhainDeviceRuntime: one loop per control, generation-guarded publication, all-node BadNoCommunication on comms loss, bounded exponential reconnect. - HeidenhainDriver: cached ReadAsync, push subscriptions deduped on (value,status), static catalog discovery (no connect), host status, diagnostics, skipped-tag report. No IWritable. - Probe (INSPECT login + version) and factory registered in DriverFactoryBootstrap (probe on admin nodes too); DriverTypeNames.Heidenhain. Tests: 159 unit tests against an in-process LSV2 simulator incl. the Java fixtures' captured iTNC 530 replies as golden vectors; env-gated LiveIntegration category for real hardware.
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
# Real iTNC530 replies captured 2026-11-03; INSPECT only, no DNC login.
|
||||
# Provenance: docs/recipes/evidence/heidenhain-live-2026-11-03/direct-inspect-only.json
|
||||
hermle49.23=S_RI:0004
|
||||
hermle49.26=S_RI:0007
|
||||
hermle49.24=S_RI:00000000544e433a5c5041525450524f475c30303538383030313330322e690000
|
||||
hermle49.25=T_ER:0117
|
||||
hermle49.22=S_RI:ff06302e30383339320031302e34333034300032352e373236373300302e30303030003237302e30303030002d39302e3030303000580059005a00430061004100
|
||||
hermle49.51=T_ER:0118
|
||||
hermle49.27=S_RI:00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300
|
||||
hermle49.28=T_ER:0139
|
||||
hermle59.23=S_RI:0004
|
||||
hermle59.26=S_RI:0007
|
||||
hermle59.24=S_RI:00000000544e433a5c7061727470726f675c30303538383030313330322e690000
|
||||
hermle59.25=T_ER:0117
|
||||
hermle59.22=S_RI:ff06302e30313131390031302e35313739330032352e333130373300302e30303030003138302e30303030002d39302e3030303000580059005a00430061004100
|
||||
hermle59.51=S_RI:000000000000000200000000000000000000000000000000
|
||||
hermle59.27=S_RI:00070004ffffffff52756e2074657374206f66206375746f7574206368616e6e656c7300
|
||||
hermle59.28=T_ER:0139
|
||||
hermle49.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000000fd00fa00000000000000000000
|
||||
hermle59.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000006fd00fa0000071e029000000000
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Real PLC boundary replies, 2026-11-03. DATA + INSPECT only; no writes.
|
||||
# Evidence: docs/recipes/evidence/heidenhain-plc-units-2026-11-03/
|
||||
hermle59.version=69544e43353330003334303439302030342053503130006943333076372e315f3100253030303031313131303030303031313100
|
||||
hermle59.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff10000000000000000000000006fd00fa0000bbd5e9c300000000
|
||||
hermle59.B.0=S_DP:0000001000
|
||||
hermle59.B.1=S_DP:0000001000
|
||||
hermle59.B.9999=S_DP:0000001000
|
||||
hermle59.B.10000=T_ER:0118
|
||||
hermle59.B.19999=T_ER:0118
|
||||
hermle59.B.20000=T_ER:0118
|
||||
hermle59.W.0=S_DP:000000020000
|
||||
hermle59.W.1=T_ER:0118
|
||||
hermle59.W.2=S_DP:000000020000
|
||||
hermle59.W.9998=S_DP:000000020000
|
||||
hermle59.W.9999=T_ER:0118
|
||||
hermle59.W.10000=T_ER:0118
|
||||
hermle59.W.19998=T_ER:0118
|
||||
hermle59.D.0=S_DP:0000000300000000
|
||||
hermle59.D.1=T_ER:0118
|
||||
hermle59.D.2=T_ER:0118
|
||||
hermle59.D.3=T_ER:0118
|
||||
hermle59.D.4=S_DP:000000036aea246c
|
||||
hermle59.D.9996=S_DP:0000000300000000
|
||||
hermle59.D.9997=T_ER:0118
|
||||
hermle59.D.10000=T_ER:0118
|
||||
hermle59.D.19996=T_ER:0118
|
||||
hermle59.IW.0=S_DP:000000020000
|
||||
hermle59.IW.1=T_ER:0118
|
||||
hermle59.IW.2=S_DP:000000020000
|
||||
hermle59.IW.998=S_DP:000000020000
|
||||
hermle59.IW.999=T_ER:0118
|
||||
hermle59.IW.1000=T_ER:0118
|
||||
hermle59.IW.1998=T_ER:0118
|
||||
hermle59.OW.0=S_DP:000000020000
|
||||
hermle59.OW.1=T_ER:0118
|
||||
hermle59.OW.2=S_DP:000000020000
|
||||
hermle59.OW.998=S_DP:000000020000
|
||||
hermle59.OW.999=T_ER:0118
|
||||
hermle59.OW.1000=T_ER:0118
|
||||
hermle59.OW.1998=T_ER:0118
|
||||
hermle59.M.0=S_DP:0000000b00
|
||||
hermle59.M.9999=S_DP:0000000b00
|
||||
hermle59.M.10000=T_ER:0118
|
||||
hermle59.I.0=S_DP:0000000b00
|
||||
hermle59.I.999=S_DP:0000000bff
|
||||
hermle59.I.1000=T_ER:0118
|
||||
hermle59.O.0=S_DP:0000000b00
|
||||
hermle59.O.999=S_DP:0000000bff
|
||||
hermle59.O.1000=T_ER:0118
|
||||
hermle59.K.0=T_ER:0118
|
||||
hermle59.T.0=S_DP:0000000b00
|
||||
hermle59.T.999=S_DP:0000000b00
|
||||
hermle59.T.1000=T_ER:0118
|
||||
hermle59.S.0=S_DP:000000083020202035302020313030203135302020202020201a453136302020202020203000
|
||||
hermle59.S.99=S_DP:0000000800
|
||||
hermle59.S.100=T_ER:0118
|
||||
hermle59.C.0=S_DP:0000000b00
|
||||
hermle59.C.1=S_DP:0000000b00
|
||||
hermle59.C.143=S_DP:0000000b00
|
||||
hermle59.C.144=T_ER:0118
|
||||
hermle70.version=69544e43353330003630363432302030332053503400694332325f5632302e315f3253503200253030303031313131303030303031313100
|
||||
hermle70.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff1000000000000000000000000efd00fa0000848c272300000000
|
||||
hermle70.B.0=S_DP:0000001002
|
||||
hermle70.B.1=S_DP:0000001000
|
||||
hermle70.B.9999=S_DP:0000001000
|
||||
hermle70.B.10000=T_ER:0118
|
||||
hermle70.B.19999=T_ER:0118
|
||||
hermle70.B.20000=T_ER:0118
|
||||
hermle70.W.0=S_DP:000000020002
|
||||
hermle70.W.1=T_ER:0118
|
||||
hermle70.W.2=S_DP:000000020000
|
||||
hermle70.W.9998=S_DP:000000020000
|
||||
hermle70.W.9999=T_ER:0118
|
||||
hermle70.W.10000=T_ER:0118
|
||||
hermle70.W.19998=T_ER:0118
|
||||
hermle70.D.0=S_DP:0000000300000002
|
||||
hermle70.D.1=T_ER:0118
|
||||
hermle70.D.2=T_ER:0118
|
||||
hermle70.D.3=T_ER:0118
|
||||
hermle70.D.4=S_DP:000000036aea678d
|
||||
hermle70.D.9996=S_DP:0000000300000000
|
||||
hermle70.D.9997=T_ER:0118
|
||||
hermle70.D.10000=T_ER:0118
|
||||
hermle70.D.19996=T_ER:0118
|
||||
hermle70.IW.0=S_DP:000000020245
|
||||
hermle70.IW.1=T_ER:0118
|
||||
hermle70.IW.2=S_DP:0000000202e0
|
||||
hermle70.IW.998=S_DP:000000020000
|
||||
hermle70.IW.999=T_ER:0118
|
||||
hermle70.IW.1000=T_ER:0118
|
||||
hermle70.IW.1998=T_ER:0118
|
||||
hermle70.OW.0=S_DP:000000020000
|
||||
hermle70.OW.1=T_ER:0118
|
||||
hermle70.OW.2=S_DP:000000020000
|
||||
hermle70.OW.998=S_DP:000000020000
|
||||
hermle70.OW.999=T_ER:0118
|
||||
hermle70.OW.1000=T_ER:0118
|
||||
hermle70.OW.1998=T_ER:0118
|
||||
hermle70.M.0=S_DP:0000000b00
|
||||
hermle70.M.9999=S_DP:0000000b00
|
||||
hermle70.M.10000=T_ER:0118
|
||||
hermle70.I.0=S_DP:0000000b01
|
||||
hermle70.I.999=S_DP:0000000b00
|
||||
hermle70.I.1000=T_ER:0118
|
||||
hermle70.O.0=S_DP:0000000b00
|
||||
hermle70.O.999=S_DP:0000000b00
|
||||
hermle70.O.1000=T_ER:0118
|
||||
hermle70.K.0=S_DP:0000000300000000
|
||||
hermle70.K.143=S_DP:000000030000008f
|
||||
hermle70.K.144=S_DP:0000000300000090
|
||||
hermle70.T.0=S_DP:0000000b00
|
||||
hermle70.T.999=S_DP:0000000b00
|
||||
hermle70.T.1000=T_ER:0118
|
||||
hermle70.S.0=S_DP:000000084f4e2f4f464600
|
||||
hermle70.S.99=S_DP:0000000800
|
||||
hermle70.S.100=T_ER:0118
|
||||
hermle70.C.0=S_DP:0000000b00
|
||||
hermle70.C.1=S_DP:0000000b00
|
||||
hermle70.C.143=S_DP:0000000b00
|
||||
hermle70.C.144=T_ER:0118
|
||||
hermle29.version=69544e43353330003630363432302030332053503400694332325f5632302e315f3253503200253030303031313131303030303031313100
|
||||
hermle29.layout=00002f200000271000005630000003e800005a18000003e800005e000000009000005e90000003e800000040000027100000627800000064800000000000000000002750000003e800002b38000003e80000000000000000000000000000000001ff1000000000000000000000000efd00fa0000a57d03a900000000
|
||||
hermle29.B.0=S_DP:0000001002
|
||||
hermle29.B.1=S_DP:0000001000
|
||||
hermle29.B.9999=S_DP:0000001000
|
||||
hermle29.B.10000=T_ER:0118
|
||||
hermle29.B.19999=T_ER:0118
|
||||
hermle29.B.20000=T_ER:0118
|
||||
hermle29.W.0=S_DP:000000020002
|
||||
hermle29.W.1=T_ER:0118
|
||||
hermle29.W.2=S_DP:000000020000
|
||||
hermle29.W.9998=S_DP:000000020000
|
||||
hermle29.W.9999=T_ER:0118
|
||||
hermle29.W.10000=T_ER:0118
|
||||
hermle29.W.19998=T_ER:0118
|
||||
hermle29.D.0=S_DP:0000000300000002
|
||||
hermle29.D.1=T_ER:0118
|
||||
hermle29.D.2=T_ER:0118
|
||||
hermle29.D.3=T_ER:0118
|
||||
hermle29.D.4=S_DP:000000036ae91e99
|
||||
hermle29.D.9996=S_DP:0000000300000000
|
||||
hermle29.D.9997=T_ER:0118
|
||||
hermle29.D.10000=T_ER:0118
|
||||
hermle29.D.19996=T_ER:0118
|
||||
hermle29.IW.0=S_DP:00000002023f
|
||||
hermle29.IW.1=T_ER:0118
|
||||
hermle29.IW.2=S_DP:000000020307
|
||||
hermle29.IW.998=S_DP:000000020000
|
||||
hermle29.IW.999=T_ER:0118
|
||||
hermle29.IW.1000=T_ER:0118
|
||||
hermle29.IW.1998=T_ER:0118
|
||||
hermle29.OW.0=S_DP:000000020000
|
||||
hermle29.OW.1=T_ER:0118
|
||||
hermle29.OW.2=S_DP:000000020000
|
||||
hermle29.OW.998=S_DP:000000020000
|
||||
hermle29.OW.999=T_ER:0118
|
||||
hermle29.OW.1000=T_ER:0118
|
||||
hermle29.OW.1998=T_ER:0118
|
||||
hermle29.M.0=S_DP:0000000b00
|
||||
hermle29.M.9999=S_DP:0000000b00
|
||||
hermle29.M.10000=T_ER:0118
|
||||
hermle29.I.0=S_DP:0000000b01
|
||||
hermle29.I.999=S_DP:0000000b00
|
||||
hermle29.I.1000=T_ER:0118
|
||||
hermle29.O.0=S_DP:0000000b00
|
||||
hermle29.O.999=S_DP:0000000b00
|
||||
hermle29.O.1000=T_ER:0118
|
||||
hermle29.K.0=S_DP:0000000300000000
|
||||
hermle29.K.143=S_DP:000000030000008f
|
||||
hermle29.K.144=S_DP:0000000300000090
|
||||
hermle29.T.0=S_DP:0000000b00
|
||||
hermle29.T.999=S_DP:0000000b00
|
||||
hermle29.T.1000=T_ER:0118
|
||||
hermle29.S.0=S_DP:000000084f4e2f4f464600
|
||||
hermle29.S.99=S_DP:0000000800
|
||||
hermle29.S.100=T_ER:0118
|
||||
hermle29.C.0=S_DP:0000000b00
|
||||
hermle29.C.1=S_DP:0000000b00
|
||||
hermle29.C.143=S_DP:0000000b00
|
||||
hermle29.C.144=T_ER:0118
|
||||
@@ -0,0 +1,163 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>TagConfig grammar, catalog and options validation (reference <c>HeidenhainBindingsTest</c> / <c>HeidenhainConfigTest</c>).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainConfigTests
|
||||
{
|
||||
private static bool Accepts(string json) => HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", json, out _, out _);
|
||||
|
||||
[Fact]
|
||||
public void Parses_both_binding_families_and_preserves_colons_in_data_paths()
|
||||
{
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"PLC:MARKER:32","dataType":"Boolean"}""", out var a, out _).ShouldBeTrue();
|
||||
a!.Address.Area.ShouldBe(HeidenhainPlcArea.Marker);
|
||||
a.Address.Index.ShouldBe(32);
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", """{"address":"DATA:/TABLE/TNC:TOOL/DOC","dataType":"String"}""", out var b, out _).ShouldBeTrue();
|
||||
b!.Address.DataPath.ShouldBe("/TABLE/TNC:TOOL/DOC");
|
||||
b.DataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":"Boolean"}""")] // area/type mismatch
|
||||
[InlineData("""{"address":"PLC:MARKER:0","dataType":"Int32"}""")]
|
||||
[InlineData("""{"address":"PLC:BYTE:-1","dataType":"Int16"}""")]
|
||||
[InlineData("""{"address":"PLC:DWORD:0","dataType":"Int16"}""")] // narrows native range
|
||||
[InlineData("""{"address":"PLC:INPUT_WORD:0","dataType":"Int16"}""")] // uint16 into int16
|
||||
[InlineData("""{"address":"PLC:STRING:0","dataType":"Int32"}""")]
|
||||
[InlineData("""{"address":"PLC:WORD:99999999999","dataType":"Int64"}""")] // > uint32
|
||||
[InlineData("""{"address":"PLC:WORD:4294967296","dataType":"Int64"}""")]
|
||||
[InlineData("""{"address":"PLC:WORDS:0","dataType":"Int64"}""")]
|
||||
[InlineData("""{"address":"PLC:WORD:0"}""")] // custom needs a dataType
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":"Float32"}""")]
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":"UInt16"}""")]
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":"8"}""")] // numerically-serialized enum
|
||||
[InlineData("""{"address":"PLC:WORD:0","dataType":8}""")]
|
||||
[InlineData("""{"address":"DATA:/a\u0000b","dataType":"String"}""")]
|
||||
[InlineData("""{"address":"DATA:/€","dataType":"String"}""")]
|
||||
[InlineData("""{"address":"DATA:","dataType":"String"}""")]
|
||||
[InlineData("""{"address":"Status/Nope"}""")]
|
||||
[InlineData("""{"address":"status/executionstate"}""")]
|
||||
[InlineData("""{"address":"Status/ExecutionState","dataType":"String"}""")] // catalog type mismatch
|
||||
[InlineData("""{"address":"Axes/Names"}""")] // array leaf without isArray
|
||||
[InlineData("""{"address":"Status/ExecutionState","isArray":true}""")] // scalar marked array
|
||||
[InlineData("""{"address":"Status/ExecutionState","writable":true}""")] // read-only driver
|
||||
[InlineData("""{"address":42}""")]
|
||||
[InlineData("""[]""")]
|
||||
[InlineData("""not json""")]
|
||||
[InlineData("")]
|
||||
public void Rejects_unsafe_mismatched_or_malformed_blobs(string json)
|
||||
{
|
||||
Accepts(json).ShouldBeFalse(json);
|
||||
HeidenhainTagDefinitionFactory.Inspect(json).ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_is_never_stricter_than_the_runtime_parse()
|
||||
{
|
||||
foreach (var e in HeidenhainCatalog.Entries)
|
||||
{
|
||||
var json = TestTags.Config(e.Address);
|
||||
Accepts(json).ShouldBeTrue(json);
|
||||
HeidenhainTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
Accepts("""{"address":"PLC:INPUT_WORD:0","dataType":"Int32","isHistorized":true}""").ShouldBeTrue();
|
||||
Accepts("""{"address":"Status/ExecutionState","dataType":"int32"}""").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Catalog_is_general_purpose_read_only_and_without_oee_mapping()
|
||||
{
|
||||
HeidenhainCatalog.Entries.ShouldContain(e => e.Address == "Axes/Positions" && e.IsArray && e.DataType == DriverDataType.Float64);
|
||||
HeidenhainCatalog.Entries.ShouldContain(e => e.Address == "Status/ExecutionState" && e.DataType == DriverDataType.Int32);
|
||||
HeidenhainCatalog.Entries.ShouldNotContain(e => e.Address.Contains("RunState") || e.Address.Contains("GoodCount") || e.Address.Contains("IdealRate"));
|
||||
HeidenhainCatalog.Entries.Select(e => e.Address).ShouldBeUnique();
|
||||
HeidenhainCatalog.Entries.Where(e => e.IsCapability).ShouldAllBe(e => e.DataType == DriverDataType.String);
|
||||
HeidenhainCatalog.Entries.Count(e => e.Address.StartsWith("Timers/")).ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_have_reference_defaults()
|
||||
{
|
||||
var o = HeidenhainOptionsParser.Parse("{}");
|
||||
o.TimeoutMs.ShouldBe(5000);
|
||||
o.Polling.StatusMs.ShouldBe(1000);
|
||||
o.Polling.CustomMs.ShouldBe(1000);
|
||||
o.Polling.SlowMs.ShouldBe(10000);
|
||||
o.Polling.MaxAlarms.ShouldBe(64);
|
||||
o.Polling.TimersEnabled.ShouldBeFalse();
|
||||
o.Access.TelemetryEnabled.ShouldBeTrue();
|
||||
o.Access.DataEnabled.ShouldBeFalse();
|
||||
new HeidenhainDeviceOptions().Port.ShouldBe(19000);
|
||||
HeidenhainOptionsParser.Validate(o).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"TimeoutMs":99}""")]
|
||||
[InlineData("""{"TimeoutMs":60001}""")]
|
||||
[InlineData("""{"Polling":{"StatusMs":50}}""")]
|
||||
[InlineData("""{"Polling":{"SlowMs":3600001}}""")]
|
||||
[InlineData("""{"Polling":{"MaxAlarms":0}}""")]
|
||||
[InlineData("""{"Polling":{"MaxAlarms":257}}""")]
|
||||
[InlineData("""{"Reconnect":{"InitialDelayMs":5000,"MaxDelayMs":1000}}""")]
|
||||
[InlineData("""{"Access":{"InspectPassword":"hunter2"}}""")]
|
||||
[InlineData("""{"Access":{"DncPassword":"secret:"}}""")]
|
||||
[InlineData("""{"Devices":[{"Host":"h","DataPassword":"plain"}]}""")]
|
||||
public void Invalid_options_are_rejected_without_echoing_credentials(string json)
|
||||
{
|
||||
var errors = HeidenhainOptionsParser.Validate(HeidenhainOptionsParser.Parse(json));
|
||||
errors.ShouldNotBeEmpty();
|
||||
string.Join(" ", errors).ShouldNotContain("hunter2");
|
||||
string.Join(" ", errors).ShouldNotContain("plain'");
|
||||
Should.Throw<InvalidOperationException>(() => HeidenhainDriver.ParseOptions(json));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret:lsv2-inspect")]
|
||||
[InlineData("env:LSV2_DNC_PASSWORD")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Credential_references_are_accepted(string? value) =>
|
||||
HeidenhainOptionsParser.IsCredentialReference(value).ShouldBeTrue();
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("http://host")]
|
||||
[InlineData("host name")]
|
||||
[InlineData("a\\b")]
|
||||
public void Device_hosts_are_validated(string host) =>
|
||||
HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = host }).ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Device_port_is_validated() =>
|
||||
HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = "10.0.0.1", Port = 0 }).ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public async Task Credentials_resolve_from_secret_store_and_environment_and_fail_closed()
|
||||
{
|
||||
var resolver = new FakeResolver(new() { ["lsv2"] = "s3cret" });
|
||||
var env = new Dictionary<string, string> { ["LSV2_PW"] = "envpw" };
|
||||
var c = new HeidenhainCredentials(resolver, n => env.GetValueOrDefault(n));
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
(await c.ResolveReferenceAsync("secret:lsv2", "INSPECT", ct)).ShouldBe("s3cret");
|
||||
(await c.ResolveReferenceAsync("env:LSV2_PW", "DNC", ct)).ShouldBe("envpw");
|
||||
(await c.ResolveReferenceAsync(null, "DNC", ct)).ShouldBe("");
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => c.ResolveReferenceAsync("secret:missing", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => c.ResolveReferenceAsync("env:UNSET", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => c.ResolveReferenceAsync("literal", "DNC", ct).AsTask());
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => new HeidenhainCredentials(null).ResolveReferenceAsync("secret:lsv2", "DNC", ct).AsTask());
|
||||
// device override wins over the driver-level reference
|
||||
var o = new HeidenhainDriverOptions { Access = new() { InspectPassword = "secret:missing" } };
|
||||
(await c.ResolveAsync(o, new HeidenhainDeviceOptions { Host = "h", InspectPassword = "secret:lsv2" }, "INSPECT", ct)).ShouldBe("s3cret");
|
||||
}
|
||||
|
||||
private sealed class FakeResolver(Dictionary<string, string> secrets) : ZB.MOM.WW.Secrets.Abstractions.ISecretResolver
|
||||
{
|
||||
public Task<string?> GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct) =>
|
||||
Task.FromResult(secrets.TryGetValue(name.Value, out var v) ? v : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end driver behaviour against the in-process LSV2 simulator: cached reads, subscription fan-out,
|
||||
/// all-node invalidation on comms loss, recovery re-publish of IDENTICAL values, bounded reconnect,
|
||||
/// re-init teardown, browse-without-connect and the read-only boundary.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainDriverTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static SimulatedControl Itnc()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
c.Runtime[26] = () => SimulatedControl.U16(7);
|
||||
c.Runtime[25] = () => SimReply.Ok("S_RI", [0, 0, 0x27, 0x10, 0, 0, 0x1F, 0x40, 0, 0, 0, 0]);
|
||||
c.Runtime[27] = () => SimReply.Refuse(57);
|
||||
c.Memory = (_, n) => SimReply.Ok("S_MB", n == 4 ? [42, 0, 0, 0] : new byte[n]);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static HeidenhainDriverOptions Options(int port, params RawTagEntry[] tags) => new()
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = "127.0.0.1", Port = port, DeviceName = "dev1" }],
|
||||
RawTags = tags,
|
||||
TimeoutMs = 500,
|
||||
Polling = new HeidenhainPollingOptions { StatusMs = 100, CustomMs = 100, SlowMs = 300 },
|
||||
Reconnect = new HeidenhainReconnectOptions { InitialDelayMs = 100, MaxDelayMs = 400 },
|
||||
};
|
||||
|
||||
private static RawTagEntry[] StandardTags() =>
|
||||
[
|
||||
TestTags.Raw("Status/ExecutionState"),
|
||||
TestTags.Raw("Overrides/Feed"),
|
||||
TestTags.Raw("Controller/Model"),
|
||||
TestTags.Raw("Alarms/Count"),
|
||||
TestTags.Raw("PLC:DWORD:0", "Int64"),
|
||||
TestTags.Raw("Capabilities/Identity"),
|
||||
];
|
||||
|
||||
private sealed class Recorder
|
||||
{
|
||||
public ConcurrentQueue<(string Ref, DataValueSnapshot Snapshot)> Events { get; } = new();
|
||||
public void Attach(HeidenhainDriver d) => d.OnDataChange += (_, e) => Events.Enqueue((e.FullReference, e.Snapshot));
|
||||
public IReadOnlyList<DataValueSnapshot> For(string rawPath) => Events.Where(e => e.Ref == rawPath).Select(e => e.Snapshot).ToList();
|
||||
public DataValueSnapshot? Last(string rawPath) => For(rawPath).LastOrDefault();
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
|
||||
await Task.Delay(20, Ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static HeidenhainDriver Create(HeidenhainDriverOptions o) =>
|
||||
new(o, "hh-1", null, new HeidenhainCredentials(null), null, null);
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_and_discovery_never_contact_the_control()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
await using var d = Create(Options(sim.Port, StandardTags()));
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
var builder = new CapturingBuilder();
|
||||
await d.DiscoverAsync(builder, Ct);
|
||||
await Task.Delay(200, Ct);
|
||||
sim.Connections.ShouldBe(0);
|
||||
builder.Variables.Count.ShouldBe(HeidenhainCatalog.Entries.Count);
|
||||
builder.Variables.ShouldContain(v => v.FullName == "Axes/Positions" && v.IsArray && v.DriverDataType == DriverDataType.Float64);
|
||||
builder.Variables.ShouldAllBe(v => v.SecurityClass == SecurityClassification.ViewOnly);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_pushes_initial_state_then_cached_good_values_and_reads_never_touch_the_wire()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tags = StandardTags();
|
||||
await using var d = Create(Options(sim.Port, tags));
|
||||
var rec = new Recorder();
|
||||
rec.Attach(d);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct);
|
||||
rec.For(tags[0].RawPath)[0].StatusCode.ShouldBe(HeidenhainStatus.BadWaitingForInitialData);
|
||||
await WaitUntil(() => rec.Last(tags[3].RawPath)?.StatusCode == HeidenhainStatus.Good
|
||||
&& rec.Last(tags[4].RawPath)?.StatusCode == HeidenhainStatus.Good);
|
||||
rec.Last(tags[0].RawPath)!.Value.ShouldBe(4);
|
||||
rec.Last(tags[1].RawPath)!.Value.ShouldBe(100.0);
|
||||
rec.Last(tags[2].RawPath)!.Value.ShouldBe("iTNC 530");
|
||||
rec.Last(tags[3].RawPath)!.Value.ShouldBe(0);
|
||||
rec.Last(tags[4].RawPath)!.Value.ShouldBe(42L);
|
||||
rec.Last(tags[5].RawPath)!.Value.ShouldBe("Supported");
|
||||
|
||||
var before = sim.Requests.Count;
|
||||
var read = await d.ReadAsync([tags[0].RawPath, "Plant/H/dev1/nope"], Ct);
|
||||
read[0].Value.ShouldBe(4);
|
||||
read[1].StatusCode.ShouldBe(HeidenhainStatus.BadNodeIdUnknown);
|
||||
sim.Requests.Count.ShouldBeLessThanOrEqualTo(before + 3); // the background loop may tick; the read itself is cache-only
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
d.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
control.Violations.ShouldBeEmpty();
|
||||
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_values_are_not_republished()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tag = TestTags.Raw("Status/ExecutionState");
|
||||
await using var d = Create(Options(sim.Port, tag));
|
||||
var rec = new Recorder();
|
||||
rec.Attach(d);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromMilliseconds(100), Ct);
|
||||
await WaitUntil(() => sim.Requests.Count(r => r.Command == "R_RI") >= 5);
|
||||
rec.For(tag.RawPath).Count.ShouldBe(2); // initial BadWaitingForInitialData + one Good
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Comms_loss_invalidates_every_remote_value_and_recovery_republishes_identical_values()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tags = StandardTags();
|
||||
await using var d = Create(Options(sim.Port, tags));
|
||||
var rec = new Recorder();
|
||||
rec.Attach(d);
|
||||
var hostEvents = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
d.OnHostStatusChanged += (_, e) => hostEvents.Enqueue(e);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct);
|
||||
await WaitUntil(() => tags.All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.Good));
|
||||
var lastGood = d.GetHealth().LastSuccessfulRead;
|
||||
|
||||
sim.GoOffline();
|
||||
// EVERY remote leaf — fast status, slow identity/alarms and custom PLC — goes Bad at once.
|
||||
await WaitUntil(() => tags.Take(5).All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.BadNoCommunication));
|
||||
rec.Last(tags[5].RawPath)!.Value.ShouldBe("CommunicationLost");
|
||||
rec.Last(tags[5].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.Good);
|
||||
(await d.ReadAsync([tags[2].RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
await WaitUntil(() => d.GetHealth().State == DriverState.Degraded);
|
||||
d.GetHealth().LastError.ShouldNotBeNullOrEmpty();
|
||||
d.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
hostEvents.ShouldContain(e => e.NewState == HostState.Stopped);
|
||||
var frozen = d.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(500, Ct);
|
||||
d.GetHealth().LastSuccessfulRead.ShouldBe(frozen); // frozen while down — the comms-loss watchdog's evidence
|
||||
(frozen <= lastGood!.Value.AddSeconds(1)).ShouldBeTrue();
|
||||
|
||||
sim.GoOnline();
|
||||
// The control comes back holding the SAME readings: the (value, status) dedup must still re-publish.
|
||||
await WaitUntil(() => tags.All(t => rec.Last(t.RawPath)?.StatusCode == HeidenhainStatus.Good), 8000);
|
||||
foreach (var t in tags.Take(5))
|
||||
{
|
||||
var seq = rec.For(t.RawPath).Select(s => s.StatusCode).ToList();
|
||||
seq.ShouldContain(HeidenhainStatus.BadNoCommunication);
|
||||
seq[^1].ShouldBe(HeidenhainStatus.Good);
|
||||
}
|
||||
|
||||
rec.Last(tags[0].RawPath)!.Value.ShouldBe(4);
|
||||
rec.Last(tags[4].RawPath)!.Value.ShouldBe(42L);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
hostEvents.Last().NewState.ShouldBe(HostState.Running);
|
||||
control.Violations.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconnect_is_bounded_exponential_backoff()
|
||||
{
|
||||
int port;
|
||||
await using (var probe = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = probe.Port;
|
||||
var tag = TestTags.Raw("Status/ExecutionState");
|
||||
var o = Options(port, tag);
|
||||
var attempts = new ConcurrentQueue<DateTime>();
|
||||
var d = new HeidenhainDriver(o, "hh-1", null, new HeidenhainCredentials(null),
|
||||
(h, p, t) => { attempts.Enqueue(DateTime.UtcNow); return new Lsv2.Lsv2Client(h, p, t); }, null);
|
||||
await using (d)
|
||||
{
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromSeconds(1), Ct);
|
||||
await Task.Delay(2000, Ct);
|
||||
var a = attempts.ToArray();
|
||||
a.Length.ShouldBeInRange(3, 8); // 100, 200, 400, 400, … ms — never a hot loop
|
||||
for (var i = 2; i < a.Length; i++)
|
||||
(a[i] - a[i - 1]).TotalMilliseconds.ShouldBeGreaterThan(150);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
(await d.ReadAsync([tag.RawPath], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refusals_are_per_tag_quality_and_do_not_degrade_driver_health()
|
||||
{
|
||||
var control = Itnc();
|
||||
control.Runtime[25] = () => SimReply.Refuse(23);
|
||||
control.Login = (role, _) => role == "DNC" ? SimReply.Refuse(23) : SimReply.Ok("T_OK", []);
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tags = new[] { TestTags.Raw("Status/ExecutionState"), TestTags.Raw("Overrides/Feed"), TestTags.Raw("PLC:WORD:1", "Int32") };
|
||||
control.Memory = null; // R_MB denied, as on every tested iTNC 530
|
||||
await using var d = Create(Options(sim.Port, tags));
|
||||
var rec = new Recorder();
|
||||
rec.Attach(d);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
await d.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromMilliseconds(100), Ct);
|
||||
await WaitUntil(() => rec.Last(tags[2].RawPath)?.StatusCode == HeidenhainStatus.BadUserAccessDenied);
|
||||
rec.Last(tags[0].RawPath)!.Value.ShouldBe(4);
|
||||
rec.Last(tags[1].RawPath)!.StatusCode.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
d.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
d.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
d.GetDiagnostics().Values.ShouldContain("Partial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_tears_down_the_previous_session_before_reconnecting()
|
||||
{
|
||||
var control = Itnc();
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
var tag = TestTags.Raw("Status/ExecutionState");
|
||||
var json = JsonSerializer.Serialize(Options(sim.Port, tag));
|
||||
await using var d = HeidenhainDriverFactoryExtensions.CreateInstance("hh-1", json);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await d.InitializeAsync(json, Ct);
|
||||
await d.SubscribeAsync([tag.RawPath], TimeSpan.FromMilliseconds(100), Ct);
|
||||
await WaitUntil(() => sim.Connections == i + 1);
|
||||
}
|
||||
|
||||
await d.ShutdownAsync(Ct);
|
||||
await Task.Delay(200, Ct);
|
||||
var after = sim.Requests.Count;
|
||||
await Task.Delay(300, Ct);
|
||||
sim.Requests.Count.ShouldBe(after); // nothing polls after shutdown
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bad_tags_and_unknown_devices_are_skipped_and_reported()
|
||||
{
|
||||
var o = Options(19000,
|
||||
TestTags.Raw("Status/ExecutionState"),
|
||||
new RawTagEntry("Plant/H/dev1/bad", """{"address":"PLC:WORD:0","dataType":"Boolean"}""", false, "dev1"),
|
||||
TestTags.Raw("Status/ProgramState", device: "other"));
|
||||
o = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [o.Devices[0], new HeidenhainDeviceOptions { Host = "127.0.0.2", DeviceName = "dev2" }],
|
||||
RawTags = o.RawTags,
|
||||
};
|
||||
await using var d = Create(o);
|
||||
await d.InitializeAsync("{}", Ct);
|
||||
var report = d.GetSkippedTags()!;
|
||||
report.AuthoredCount.ShouldBe(3);
|
||||
report.SkippedCount.ShouldBe(2);
|
||||
(await d.ReadAsync(["Plant/H/dev1/bad"], Ct))[0].StatusCode.ShouldBe(HeidenhainStatus.BadNodeIdUnknown);
|
||||
d.ResolveHost("Plant/H/dev1/Status/ExecutionState").ShouldBe("127.0.0.1:19000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Factory_registers_under_the_canonical_type_and_rejects_literal_passwords()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
HeidenhainDriverFactoryExtensions.Register(registry);
|
||||
var json = """{"Devices":[{"Host":"10.0.0.5","DeviceName":"d"}],"Access":{"InspectPassword":"secret:lsv2/inspect"}}""";
|
||||
var driver = registry.TryGet(DriverTypeNames.Heidenhain)!("hh", json);
|
||||
driver.ShouldBeOfType<HeidenhainDriver>();
|
||||
driver.DriverType.ShouldBe(DriverTypeNames.Heidenhain);
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
HeidenhainDriverFactoryExtensions.CreateInstance("hh", """{"Access":{"InspectPassword":"plain"}}"""));
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class CapturingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<DriverAttributeInfo> Variables { get; } = [];
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add(attributeInfo);
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullReference) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullReference;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Real-controller commissioning gate — env-gated, skipped unless <c>HEIDENHAIN_LSV2_HOST</c> is set. READ-ONLY:
|
||||
/// it issues only the allowlisted INSPECT login, version, layout and runtime-info reads that the driver itself
|
||||
/// uses (every frame goes through <see cref="Lsv2ReadOnlyPolicy"/>). Run with
|
||||
/// <c>dotnet test --filter Category=LiveIntegration</c>; record the output as .NET commissioning evidence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Env: <c>HEIDENHAIN_LSV2_HOST</c> (required), <c>HEIDENHAIN_LSV2_PORT</c> (default 19000),
|
||||
/// <c>HEIDENHAIN_LSV2_INSPECT_PASSWORD</c> (optional; never logged).
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class HeidenhainLiveIntegrationTests
|
||||
{
|
||||
private static string? Host => Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_HOST");
|
||||
|
||||
private static int Port => int.TryParse(Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_PORT"), out var p) ? p : 19000;
|
||||
|
||||
[Fact]
|
||||
public async Task Real_control_inspect_identity_layout_and_status()
|
||||
{
|
||||
Assert.SkipWhen(string.IsNullOrWhiteSpace(Host), "HEIDENHAIN_LSV2_HOST not set — real-controller gate skipped.");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var client = new Lsv2Client(Host!, Port, TimeSpan.FromSeconds(5));
|
||||
await client.OpenAsync(ct);
|
||||
await client.LoginAsync("INSPECT", Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_INSPECT_PASSWORD") ?? "", ct);
|
||||
var versions = Lsv2Data.Versions(await client.ReadVersionAsync(ct));
|
||||
TestContext.Current.SendDiagnosticMessage($"model={versions.Model} nc={versions.Nc} plc={versions.Plc}");
|
||||
versions.Model.ShouldNotBeNullOrWhiteSpace();
|
||||
foreach (var selector in new[] { 23, 26, 24, 25, 22, 51 })
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = await client.ReadRuntimeAsync(selector, ct);
|
||||
TestContext.Current.SendDiagnosticMessage($"R_RI {selector}: {bytes.Length} bytes");
|
||||
}
|
||||
catch (Lsv2RefusalException ex)
|
||||
{
|
||||
TestContext.Current.SendDiagnosticMessage($"R_RI {selector}: refused code {ex.Code} -> {HeidenhainStatus.State(HeidenhainStatus.FromRefusal(ex.Code))}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var layout = Lsv2Data.Layout(await client.ReadParametersAsync(ct));
|
||||
TestContext.Current.SendDiagnosticMessage($"R_PR WORD region base={layout.Region(HeidenhainPlcArea.Word)!.Base} bytes={layout.Region(HeidenhainPlcArea.Word)!.Bytes}");
|
||||
}
|
||||
catch (Lsv2RefusalException ex)
|
||||
{
|
||||
TestContext.Current.SendDiagnosticMessage($"R_PR refused code {ex.Code}");
|
||||
}
|
||||
|
||||
client.IsClosed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_control_driver_end_to_end_status_poll()
|
||||
{
|
||||
Assert.SkipWhen(string.IsNullOrWhiteSpace(Host), "HEIDENHAIN_LSV2_HOST not set — real-controller gate skipped.");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var tags = new[] { TestTags.Raw("Controller/Model"), TestTags.Raw("Status/ExecutionState"), TestTags.Raw("Capabilities/Execution") };
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = Host!, Port = Port, DeviceName = "dev1" }],
|
||||
RawTags = tags,
|
||||
Access = new HeidenhainAccessOptions { InspectPassword = Environment.GetEnvironmentVariable("HEIDENHAIN_LSV2_INSPECT_PASSWORD") is { Length: > 0 } ? "env:HEIDENHAIN_LSV2_INSPECT_PASSWORD" : null },
|
||||
};
|
||||
await using var driver = new HeidenhainDriver(options, "live");
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
await driver.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromSeconds(1), ct);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
IReadOnlyList<Core.Abstractions.DataValueSnapshot> values;
|
||||
do
|
||||
{
|
||||
await Task.Delay(500, ct);
|
||||
values = await driver.ReadAsync(tags.Select(t => t.RawPath).ToList(), ct);
|
||||
} while (values[0].StatusCode != HeidenhainStatus.Good && DateTime.UtcNow < deadline);
|
||||
|
||||
for (var i = 0; i < tags.Length; i++)
|
||||
TestContext.Current.SendDiagnosticMessage($"{tags[i].RawPath} = {values[i].Value} (0x{values[i].StatusCode:X8})");
|
||||
values[0].StatusCode.ShouldBe(HeidenhainStatus.Good);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Replays the actual DATA boundary replies captured on three iTNC 530 controls (reference
|
||||
/// <c>HeidenhainPlcUnitsTest</c>): R_PR numeric counts are BYTE capacities, binding indexes are ELEMENT
|
||||
/// indexes, and the DATA Boolean FF encoding is accepted.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainPlcUnitsTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle59")]
|
||||
[InlineData("hermle70")]
|
||||
[InlineData("hermle29")]
|
||||
public async Task Real_boundaries_match_region_byte_counts_and_element_widths(string machine)
|
||||
{
|
||||
var f = Fixtures.Load("plc-count-units.properties");
|
||||
var control = new SimulatedControl
|
||||
{
|
||||
Version = Convert.FromHexString(f[machine + ".version"]),
|
||||
Layout = Convert.FromHexString(f[machine + ".layout"]),
|
||||
};
|
||||
foreach (var (key, value) in f)
|
||||
{
|
||||
if (!key.StartsWith(machine + ".", StringComparison.Ordinal) || key.EndsWith(".version", StringComparison.Ordinal) || key.EndsWith(".layout", StringComparison.Ordinal)) continue;
|
||||
var parts = key.Split('.');
|
||||
control.DataPaths[$"/PLC/memory/{parts[1]}/{parts[2]}"] = SimReply.FromFixture(value);
|
||||
}
|
||||
|
||||
await using var sim = Lsv2Simulator.WithControl(control);
|
||||
await using var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
await client.OpenAsync(Ct);
|
||||
await client.LoginAsync("INSPECT", "", Ct);
|
||||
await client.LoginAsync("DATA", "", Ct);
|
||||
var layout = Lsv2Data.Layout(await client.ReadParametersAsync(Ct));
|
||||
|
||||
foreach (var (token, area) in new[] { ("B", HeidenhainPlcArea.Byte), ("W", HeidenhainPlcArea.Word), ("D", HeidenhainPlcArea.Dword), ("IW", HeidenhainPlcArea.InputWord), ("OW", HeidenhainPlcArea.OutputWord) })
|
||||
{
|
||||
var region = layout.Region(area)!;
|
||||
long expectedBytes = token.Length == 1 ? 10000 : 1000;
|
||||
region.Bytes.ShouldBe(expectedBytes, $"{machine} {area}");
|
||||
var lastOffset = expectedBytes - region.Width;
|
||||
Lsv2Data.DataPath(await client.ReadDataPathAsync($"/PLC/memory/{token}/{lastOffset}", Ct)).ShouldNotBeNull();
|
||||
(await Should.ThrowAsync<Lsv2RefusalException>(() => client.ReadDataPathAsync($"/PLC/memory/{token}/{expectedBytes}", Ct))).Code.ShouldBe(24);
|
||||
HeidenhainBindingCodec.Range(area, lastOffset / region.Width, layout).Address.ShouldBe(region.Base + lastOffset);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(area, lastOffset / region.Width + 1, layout));
|
||||
}
|
||||
|
||||
foreach (var token in new[] { "W", "D", "IW", "OW" })
|
||||
(await Should.ThrowAsync<Lsv2RefusalException>(() => client.ReadDataPathAsync($"/PLC/memory/{token}/1", Ct))).Code.ShouldBe(24);
|
||||
|
||||
foreach (var (token, area, entries) in new[]
|
||||
{
|
||||
("M", HeidenhainPlcArea.Marker, 10000), ("I", HeidenhainPlcArea.Input, 1000), ("O", HeidenhainPlcArea.Output, 1000),
|
||||
("C", HeidenhainPlcArea.Counter, 144), ("T", HeidenhainPlcArea.Timer, 1000), ("S", HeidenhainPlcArea.String, 100),
|
||||
})
|
||||
{
|
||||
var region = layout.Region(area)!;
|
||||
region.Bytes.ShouldBe((long)entries * region.Width);
|
||||
Lsv2Data.DataPath(await client.ReadDataPathAsync($"/PLC/memory/{token}/{entries - 1}", Ct)).ShouldNotBeNull();
|
||||
(await Should.ThrowAsync<Lsv2RefusalException>(() => client.ReadDataPathAsync($"/PLC/memory/{token}/{entries}", Ct))).Code.ShouldBe(24);
|
||||
}
|
||||
|
||||
// Observed FF-true on a real output slot.
|
||||
if (f.TryGetValue(machine + ".O.999", out var o999) && o999.EndsWith("ff", StringComparison.OrdinalIgnoreCase))
|
||||
Lsv2Data.DataPath(await client.ReadDataPathAsync("/PLC/memory/O/999", Ct)).ShouldBe(true);
|
||||
control.Violations.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>Session read-policy regression ported from the reference <c>HeidenhainSessionTest</c>.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainSessionTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private sealed class Harness : IAsyncDisposable
|
||||
{
|
||||
public required Lsv2Simulator Sim { get; init; }
|
||||
public required SimulatedControl Control { get; init; }
|
||||
public required HeidenhainSession Session { get; init; }
|
||||
public required IReadOnlyList<HeidenhainTagDefinition> Tags { get; init; }
|
||||
|
||||
public async Task<Dictionary<string, HeidenhainSample>> ReadAsync(params string[] addresses)
|
||||
{
|
||||
var tags = addresses.Select(a => Tags.First(t => t.Address.Text == a || t.RawPath.EndsWith("/" + a, StringComparison.Ordinal))).ToList();
|
||||
var samples = await Session.ReadBatchAsync(tags, Ct);
|
||||
var map = new Dictionary<string, HeidenhainSample>();
|
||||
for (var i = 0; i < tags.Count; i++) map[addresses[i]] = samples[i];
|
||||
return map;
|
||||
}
|
||||
|
||||
public int RuntimeRequests(int? selector = null) =>
|
||||
Sim.Requests.Count(r => r.Command == "R_RI" && (selector is null || r.Selector == selector));
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Session.DisposeAsync();
|
||||
Control.Violations.ShouldBeEmpty();
|
||||
await Sim.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Harness> OpenAsync(
|
||||
SimulatedControl control, IReadOnlyList<HeidenhainTagDefinition> tags,
|
||||
bool telemetry = true, bool dnc = true, bool data = false, int maxAlarms = 64, bool timers = false,
|
||||
HeidenhainSession.PasswordSource? passwords = null)
|
||||
{
|
||||
var sim = Lsv2Simulator.WithControl(control);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var settings = new HeidenhainSessionSettings(telemetry, dnc, data, maxAlarms, timers,
|
||||
tags.Any(t => t.Address.Kind == HeidenhainAddressKind.Plc), tags.Any(t => t.Address.Kind == HeidenhainAddressKind.DataPath));
|
||||
var session = new HeidenhainSession(client, settings, passwords ?? ((_, _) => ValueTask.FromResult("")));
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
return new Harness { Sim = sim, Control = control, Session = session, Tags = tags };
|
||||
}
|
||||
|
||||
private static List<HeidenhainTagDefinition> Standard(params string[] addresses) => addresses.Select(a => TestTags.Def(a)).ToList();
|
||||
|
||||
private static SimReply DenyDnc(string role, string _) => role == "DNC" ? SimReply.Refuse(23) : SimReply.Ok("T_OK", []);
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_shares_reads_and_preserves_unknown_native_state()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
var program = new byte[8];
|
||||
BinaryPrimitives.WriteInt32BigEndian(program, 5);
|
||||
"A\0B\0"u8.ToArray().CopyTo(program, 4);
|
||||
c.Runtime[24] = () => SimReply.Ok("S_RI", program);
|
||||
c.Runtime[25] = () => SimReply.Ok("S_RI", new byte[12]);
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [1, 44]);
|
||||
await using var h = await OpenAsync(c, Standard("Program/Main", "Program/Current", "Program/Line", "Overrides/Feed", "Overrides/Spindle", "Status/ExecutionState"));
|
||||
var v = await h.ReadAsync("Program/Main", "Program/Current", "Program/Line", "Overrides/Feed", "Overrides/Spindle", "Status/ExecutionState");
|
||||
v.Values.ShouldAllBe(s => s.Status == HeidenhainStatus.Good);
|
||||
h.RuntimeRequests(24).ShouldBe(1);
|
||||
h.RuntimeRequests(25).ShouldBe(1);
|
||||
v["Status/ExecutionState"].Value.ShouldBe(300); // raw enum preserved, not mapped
|
||||
v["Program/Line"].Value.ShouldBe(5L);
|
||||
v["Program/Current"].Value.ShouldBe("B");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(57, true)]
|
||||
[InlineData(23, false)]
|
||||
[InlineData(24, false)]
|
||||
[InlineData(25, false)]
|
||||
public async Task Only_refusal_57_proves_an_empty_alarm_list(int code, bool empty)
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[27] = () => SimReply.Refuse(code);
|
||||
await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Messages"));
|
||||
var v = await h.ReadAsync("Alarms/Count", "Alarms/Messages");
|
||||
if (empty)
|
||||
{
|
||||
v["Alarms/Count"].Value.ShouldBe(0);
|
||||
v["Alarms/Messages"].Value.ShouldBe(Array.Empty<string>());
|
||||
v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
}
|
||||
else
|
||||
{
|
||||
v.Values.ShouldAllBe(s => s.Status != HeidenhainStatus.Good && s.Value == null);
|
||||
}
|
||||
}
|
||||
|
||||
private static SimReply AlarmRecord()
|
||||
{
|
||||
var b = new byte[10];
|
||||
BinaryPrimitives.WriteInt16BigEndian(b, 3);
|
||||
BinaryPrimitives.WriteInt16BigEndian(b.AsSpan(2), 4);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(4), -1);
|
||||
b[8] = 65;
|
||||
return SimReply.Ok("S_RI", b);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public async Task Alarm_cursor_end_at_the_limit_is_good(int limit)
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[27] = AlarmRecord;
|
||||
c.Runtime[28] = () => SimReply.Refuse(57);
|
||||
await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Numbers", "Alarms/Classes"), maxAlarms: limit);
|
||||
var v = await h.ReadAsync("Alarms/Count", "Alarms/Numbers", "Alarms/Classes");
|
||||
v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
v["Alarms/Count"].Value.ShouldBe(1);
|
||||
v["Alarms/Numbers"].Value.ShouldBe(new[] { -1 });
|
||||
v["Alarms/Classes"].Value.ShouldBe(new[] { 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Alarm_limit_exhaustion_is_out_of_range_never_truncated()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[27] = AlarmRecord;
|
||||
c.Runtime[28] = AlarmRecord;
|
||||
await using var h = await OpenAsync(c, Standard("Alarms/Count", "Alarms/Messages"), maxAlarms: 1);
|
||||
var v = await h.ReadAsync("Alarms/Count", "Alarms/Messages");
|
||||
v.Values.ShouldAllBe(s => s.Status == HeidenhainStatus.BadOutOfRange && s.Value == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Malformed_alarm_record_is_a_decoding_error_not_an_empty_list()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[27] = () => SimReply.Ok("S_RI", [0, 0, 0, 0, 0, 0, 0, 0, 65]); // unterminated text
|
||||
await using var h = await OpenAsync(c, Standard("Alarms/Count", "Capabilities/Alarms"));
|
||||
var v = await h.ReadAsync("Alarms/Count", "Capabilities/Alarms");
|
||||
v["Alarms/Count"].Status.ShouldBe(HeidenhainStatus.BadDecodingError);
|
||||
v["Capabilities/Alarms"].Value.ShouldBe("InvalidData");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Denied_dnc_leaves_inspect_telemetry_available_and_honours_each_read_refusal()
|
||||
{
|
||||
var c = new SimulatedControl { Login = DenyDnc };
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]);
|
||||
c.Runtime[25] = () => SimReply.Refuse(23);
|
||||
await using var h = await OpenAsync(c, Standard("Controller/Model", "Status/ExecutionState", "Overrides/Feed", "Capabilities/DncLogin", "Capabilities/Overrides"));
|
||||
var v = await h.ReadAsync("Controller/Model", "Status/ExecutionState", "Overrides/Feed", "Capabilities/DncLogin", "Capabilities/Overrides");
|
||||
v["Controller/Model"].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
v["Status/ExecutionState"].Value.ShouldBe(4);
|
||||
v["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
v["Capabilities/DncLogin"].Value.ShouldBe("AccessDenied");
|
||||
v["Capabilities/Overrides"].Value.ShouldBe("AccessDenied");
|
||||
h.RuntimeRequests().ShouldBe(2);
|
||||
// a stable denial is suppressed for the rest of the session
|
||||
(await h.ReadAsync("Overrides/Feed"))["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
h.RuntimeRequests().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Telemetry_opt_out_prevents_every_runtime_request()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]);
|
||||
await using var h = await OpenAsync(c, Standard("Status/ExecutionState", "Capabilities/Execution", "Capabilities/DncLogin"), telemetry: false);
|
||||
var v = await h.ReadAsync("Status/ExecutionState", "Capabilities/Execution", "Capabilities/DncLogin");
|
||||
v["Status/ExecutionState"].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
v["Capabilities/Execution"].Value.ShouldBe("Disabled");
|
||||
v["Capabilities/DncLogin"].Value.ShouldBe("Disabled");
|
||||
h.RuntimeRequests().ShouldBe(0);
|
||||
h.Control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Experimental_timers_are_disabled_by_default_and_never_requested()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[30] = () => SimReply.Ok("S_RI", [0, 0, 0, 9]);
|
||||
await using var h = await OpenAsync(c, Standard("Timers/NcUptime", "Capabilities/Timer_NcUptime"));
|
||||
var v = await h.ReadAsync("Timers/NcUptime", "Capabilities/Timer_NcUptime");
|
||||
v["Timers/NcUptime"].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
v["Capabilities/Timer_NcUptime"].Value.ShouldBe("Disabled");
|
||||
h.RuntimeRequests(30).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabled_timers_read_raw_uint32()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[34] = () => SimReply.Ok("S_RI", [0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
await using var h = await OpenAsync(c, Standard("Timers/Spindle1Runtime"), timers: true);
|
||||
(await h.ReadAsync("Timers/Spindle1Runtime"))["Timers/Spindle1Runtime"].Value.ShouldBe(4294967295L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tool_without_geometry_is_not_supported_not_zero()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[51] = () => SimReply.Ok("S_RI", [0, 0, 0, 7, 0, 1, 0, 2]);
|
||||
await using var h = await OpenAsync(c, Standard("Tool/Number", "Tool/Index", "Tool/AxisCode", "Tool/Length", "Tool/Radius"));
|
||||
var v = await h.ReadAsync("Tool/Number", "Tool/Index", "Tool/AxisCode", "Tool/Length", "Tool/Radius");
|
||||
v["Tool/Number"].Value.ShouldBe(7L);
|
||||
v["Tool/Index"].Value.ShouldBe(1);
|
||||
v["Tool/AxisCode"].Value.ShouldBe(2);
|
||||
v["Tool/Length"].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
v["Tool/Length"].Value.ShouldBeNull();
|
||||
v["Tool/Radius"].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle49")]
|
||||
[InlineData("hermle59")]
|
||||
public async Task Captured_inspect_only_replies_publish_typed_telemetry_after_dnc_denial(string machine)
|
||||
{
|
||||
var f = Fixtures.Load("itnc530-inspect.properties");
|
||||
var c = new SimulatedControl { Login = DenyDnc };
|
||||
foreach (var s in new[] { 22, 23, 24, 25, 26, 27, 28, 51 })
|
||||
{
|
||||
var reply = SimReply.FromFixture(f[$"{machine}.{s}"]);
|
||||
c.Runtime[s] = () => reply;
|
||||
}
|
||||
|
||||
string[] addresses = ["Status/ExecutionState", "Status/ProgramState", "Program/Main", "Axes/Names", "Axes/Positions",
|
||||
"Alarms/Count", "Alarms/Messages", "Alarms/Classes", "Overrides/Feed", "Tool/Number"];
|
||||
await using var h = await OpenAsync(c, Standard(addresses));
|
||||
var v = await h.ReadAsync(addresses);
|
||||
v["Status/ExecutionState"].Value.ShouldBe(4);
|
||||
v["Status/ProgramState"].Value.ShouldBe(7);
|
||||
((string)v["Program/Main"].Value!).ShouldStartWith("TNC:");
|
||||
v["Axes/Names"].Value.ShouldBe(new[] { "X", "Y", "Z", "C", "a", "A" });
|
||||
((double[])v["Axes/Positions"].Value!).Length.ShouldBe(6);
|
||||
v["Alarms/Count"].Value.ShouldBe(1);
|
||||
v["Alarms/Messages"].Value.ShouldBe(new[] { "Run test of cutout channels" });
|
||||
v["Alarms/Classes"].Value.ShouldBe(new[] { 7 });
|
||||
v["Overrides/Feed"].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
v["Tool/Number"].Status.ShouldBe(machine == "hermle49" ? HeidenhainStatus.BadNotSupported : HeidenhainStatus.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Data_path_type_mismatch_is_bad_and_aliases_share_one_read()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
var tags = new List<HeidenhainTagDefinition>
|
||||
{
|
||||
TestTags.Def("DATA:/x", "Int64", "Plant/H/dev1/A"),
|
||||
TestTags.Def("DATA:/x", "String", "Plant/H/dev1/B"),
|
||||
};
|
||||
await using var h = await OpenAsync(c, tags, telemetry: false, data: true);
|
||||
var samples = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
samples[0].Value.ShouldBe(42L);
|
||||
samples[1].Status.ShouldBe(HeidenhainStatus.BadTypeMismatch);
|
||||
h.Sim.Requests.Count(r => r.Command == "R_DP").ShouldBe(1);
|
||||
h.Control.Logins.Select(l => l.Role).ShouldBe(new[] { "INSPECT", "DATA" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Data_bindings_stay_disabled_until_data_access_is_enabled()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
var tags = new List<HeidenhainTagDefinition> { TestTags.Def("DATA:/x", "Int64"), TestTags.Def("Capabilities/DataLogin") };
|
||||
await using var h = await OpenAsync(c, tags, data: false);
|
||||
var s = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
s[1].Value.ShouldBe("Disabled");
|
||||
h.Sim.Requests.ShouldNotContain(r => r.Command == "R_DP");
|
||||
h.Control.Logins.ShouldNotContain(l => l.Role == "DATA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_failure_invalidates_the_batch_and_close_prevents_good_reads()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[23] = SimReply.Drop;
|
||||
var tags = Standard("Controller/Model", "Status/ExecutionState", "Capabilities/Identity");
|
||||
await using var h = await OpenAsync(c, tags);
|
||||
var v = await h.ReadAsync("Controller/Model", "Status/ExecutionState", "Capabilities/Identity");
|
||||
h.Session.CommunicationLost.ShouldBeTrue();
|
||||
v["Controller/Model"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
v["Status/ExecutionState"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
v["Capabilities/Identity"].Value.ShouldBe("CommunicationLost");
|
||||
await h.Session.DisposeAsync();
|
||||
(await h.ReadAsync("Controller/Model"))["Controller/Model"].Status.ShouldBe(HeidenhainStatus.BadNoCommunication);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DNC")]
|
||||
[InlineData("DATA")]
|
||||
public async Task Temporary_optional_login_refusal_retries_with_a_fresh_credential(string role)
|
||||
{
|
||||
var attempts = 0;
|
||||
var c = new SimulatedControl
|
||||
{
|
||||
Login = (r, pw) => r == role && Interlocked.Increment(ref attempts) == 1 ? SimReply.Refuse(25) : SimReply.Ok("T_OK", []),
|
||||
};
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 3]);
|
||||
c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
var resolved = 0;
|
||||
var tags = role == "DATA" ? [TestTags.Def("DATA:/x", "Int64")] : Standard("Status/ExecutionState");
|
||||
await using var h = await OpenAsync(c, tags, data: true,
|
||||
passwords: (r, _) => { if (r == role) Interlocked.Increment(ref resolved); return ValueTask.FromResult("fresh"); });
|
||||
var s = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
attempts.ShouldBe(2);
|
||||
resolved.ShouldBe(2); // resolved on demand for each login — never cached
|
||||
h.Control.Logins.Where(l => l.Role == role).ShouldAllBe(l => l.Password == "fresh");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Temporary_or_malformed_layout_recovers_and_aliases_read_once_per_batch(bool malformed)
|
||||
{
|
||||
var layouts = 0;
|
||||
var c = new SimulatedControl
|
||||
{
|
||||
Intercept = r => r.Command == "R_PR" && Interlocked.Increment(ref layouts) == 1
|
||||
? (malformed ? SimReply.Ok("S_PR", new byte[1]) : SimReply.Refuse(25))
|
||||
: null,
|
||||
Memory = (_, n) => SimReply.Ok("S_MB", [42, 0, 0, 0]),
|
||||
};
|
||||
var tags = new List<HeidenhainTagDefinition>
|
||||
{
|
||||
TestTags.Def("PLC:DWORD:0", "Int64", "Plant/H/dev1/A"),
|
||||
TestTags.Def("PLC:DWORD:0", "Int32", "Plant/H/dev1/B"),
|
||||
};
|
||||
await using var h = await OpenAsync(c, tags, telemetry: false);
|
||||
var s = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
s.ShouldAllBe(x => x.Status == HeidenhainStatus.Good);
|
||||
s[0].Value.ShouldBe(42L);
|
||||
s[1].Value.ShouldBe(42);
|
||||
layouts.ShouldBe(2);
|
||||
h.Sim.Requests.Count(r => r.Command == "R_MB").ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plc_binding_outside_the_advertised_region_is_out_of_range_without_a_wire_read()
|
||||
{
|
||||
var c = new SimulatedControl { Memory = (_, _) => SimReply.Ok("S_MB", [0, 0]) };
|
||||
var tags = new List<HeidenhainTagDefinition> { TestTags.Def("PLC:WORD:5", "Int32"), TestTags.Def("PLC:WORD:4", "Int32") };
|
||||
await using var h = await OpenAsync(c, tags, telemetry: false);
|
||||
var s = await h.Session.ReadBatchAsync(tags, Ct);
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.BadOutOfRange);
|
||||
s[1].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
var mb = h.Sim.Requests.Single(r => r.Command == "R_MB");
|
||||
BinaryPrimitives.ReadUInt32BigEndian(mb.Payload).ShouldBe(1008u);
|
||||
mb.Payload[4].ShouldBe((byte)2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plc_memory_denial_is_access_denied_and_suppressed()
|
||||
{
|
||||
var c = new SimulatedControl(); // R_MB → T_ER 23, as on all four tested iTNC 530 controls
|
||||
var tags = new List<HeidenhainTagDefinition> { TestTags.Def("PLC:WORD:1", "Int32") };
|
||||
await using var h = await OpenAsync(c, tags, telemetry: false);
|
||||
(await h.Session.ReadBatchAsync(tags, Ct))[0].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
(await h.Session.ReadBatchAsync(tags, Ct))[0].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
h.Sim.Requests.Count(r => r.Command == "R_MB").ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_transient_dnc_failures_cannot_permanently_suppress_telemetry()
|
||||
{
|
||||
var logins = 0;
|
||||
var c = new SimulatedControl
|
||||
{
|
||||
Login = (r, _) => r == "DNC" && Interlocked.Increment(ref logins) <= 2 ? SimReply.Refuse(25) : SimReply.Ok("T_OK", []),
|
||||
};
|
||||
c.Runtime[23] = () => logins < 3 ? SimReply.Refuse(23) : SimReply.Ok("S_RI", [0, 4]);
|
||||
c.Runtime[26] = () => logins < 3 ? SimReply.Refuse(23) : SimReply.Ok("S_RI", [0, 7]);
|
||||
await using var h = await OpenAsync(c, Standard("Status/ExecutionState", "Status/ProgramState"));
|
||||
(await h.ReadAsync("Status/ExecutionState", "Status/ProgramState")).Values
|
||||
.ShouldAllBe(s => s.Status == HeidenhainStatus.BadUserAccessDenied);
|
||||
var recovered = await h.ReadAsync("Status/ExecutionState", "Status/ProgramState");
|
||||
recovered["Status/ExecutionState"].Value.ShouldBe(4);
|
||||
recovered["Status/ProgramState"].Value.ShouldBe(7);
|
||||
logins.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_request_the_session_emits_is_allowlisted()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
foreach (var s in new[] { 22, 23, 24, 25, 26, 51 }) c.Runtime[s] = () => SimReply.Refuse(24);
|
||||
c.Runtime[27] = () => SimReply.Refuse(57);
|
||||
var tags = HeidenhainCatalog.Entries.Select(e => TestTags.Def(e.Address)).ToList();
|
||||
tags.Add(TestTags.Def("PLC:WORD:1", "Int32"));
|
||||
await using var h = await OpenAsync(c, tags, timers: true);
|
||||
await h.Session.ReadBatchAsync(tags, Ct);
|
||||
await h.Session.ReadBatchAsync(tags, Ct);
|
||||
h.Sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB" });
|
||||
h.Sim.Requests.Where(r => r.Command == "A_LG").Select(r => Encoding.Latin1.GetString(r.Payload).Split('\0')[0])
|
||||
.ShouldBeSubsetOf(new[] { "INSPECT", "DNC", "DATA" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>Decoder regression ported from the reference <c>Lsv2DataTest</c> + <c>HeidenhainBindingsTest</c>.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Lsv2DataTests
|
||||
{
|
||||
private static byte[] T(string s) => Encoding.Latin1.GetBytes(s);
|
||||
|
||||
[Fact]
|
||||
public void Version_and_program_preserve_native_values()
|
||||
{
|
||||
Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0OPT\0")).Model.ShouldBe("iTNC 530");
|
||||
Lsv2Data.Versions(T("TNC\0NC\0PLC\0SPLC\0OPT\0")).Options.ShouldBe("OPT");
|
||||
var program = new byte[10];
|
||||
BinaryPrimitives.WriteInt32BigEndian(program, -1);
|
||||
T("A\0B\0\0\0").CopyTo(program, 4);
|
||||
var p = Lsv2Data.Program(program);
|
||||
p.Line.ShouldBe(4294967295L);
|
||||
p.Current.ShouldBe("B");
|
||||
Lsv2Data.U16([0xFF, 0xFE]).ShouldBe(65534);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_strings_and_lengths_are_rejected()
|
||||
{
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Versions(T("TNC\0NC\0PLC\0OPT")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Versions(T("TNC\0NC\0")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Program(new byte[4]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Overrides(new byte[11]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Layout(new byte[119]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.U16(new byte[3]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Alarm(new byte[8]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_version_options_do_not_become_the_optional_splc_field()
|
||||
{
|
||||
Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0SPLC\0\0")).Options.ShouldBe("");
|
||||
Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0\0")).Options.ShouldBe("");
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Versions(T("iTNC 530\0NC\0PLC\0SPLC\0OPT\0\0")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_are_unsigned_hundredths_and_tool_is_mixed_endian()
|
||||
{
|
||||
var o = new byte[12];
|
||||
BinaryPrimitives.WriteInt32BigEndian(o, 12345);
|
||||
BinaryPrimitives.WriteInt32BigEndian(o.AsSpan(4), 8000);
|
||||
Lsv2Data.Overrides(o).ShouldBe(new[] { 123.45, 80, 0 });
|
||||
var t = new byte[24];
|
||||
BinaryPrimitives.WriteInt32BigEndian(t, -1);
|
||||
BinaryPrimitives.WriteInt16BigEndian(t.AsSpan(4), 4);
|
||||
BinaryPrimitives.WriteInt16BigEndian(t.AsSpan(6), 2);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), 123.5);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(16), 4.25);
|
||||
var tool = Lsv2Data.Tool(t);
|
||||
tool.Number.ShouldBe(4294967295L);
|
||||
tool.Length.ShouldBe(123.5);
|
||||
tool.Radius.ShouldBe(4.25);
|
||||
Lsv2Data.Tool(new byte[8]).Length.ShouldBeNull();
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Tool(new byte[9]));
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), double.NaN);
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Tool(t));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Axes_are_aligned_with_no_guessed_units()
|
||||
{
|
||||
var a = Lsv2Data.Axes(T("\0\u0002-1.25\u00002.5\0X\0C\0"));
|
||||
a.Names.ShouldBe(new[] { "X", "C" });
|
||||
a.Positions.ShouldBe(new[] { -1.25, 2.5 });
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Axes(T("\0\u0002NaN\u00002\0X\0Y\0")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Axes(T("\0\u00021\u00002\0X\0X\0")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Axes(T("\0\u00021\u00002\0X\0")));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.Axes(T("\0\u0002abc\u00002\0X\0Y\0")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_number_is_signed_and_data_path_types_are_strict()
|
||||
{
|
||||
var e = new byte[12];
|
||||
BinaryPrimitives.WriteInt16BigEndian(e, 3);
|
||||
BinaryPrimitives.WriteInt16BigEndian(e.AsSpan(2), 4);
|
||||
BinaryPrimitives.WriteInt32BigEndian(e.AsSpan(4), -10);
|
||||
T("Err\0").CopyTo(e, 8);
|
||||
Lsv2Data.Alarm(e).Number.ShouldBe(-10);
|
||||
Lsv2Data.DataPath([0, 0, 0, 2, 0xFF, 0xFE]).ShouldBe(-2);
|
||||
Lsv2Data.DataPath([0, 0, 0, 17, 0xFF]).ShouldBe(255);
|
||||
Lsv2Data.DataPath([0, 0, 0, 16, 0xFF]).ShouldBe(-1);
|
||||
Lsv2Data.DataPath([0, 0, 0, 11, 1]).ShouldBe(true);
|
||||
Lsv2Data.DataPath([0, 0, 0, 8, 65, 66, 0]).ShouldBe("AB");
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.DataPath([0, 0, 0, 5]));
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.DataPath([0, 0, 0, 99, 1]));
|
||||
var inf = new byte[12];
|
||||
BinaryPrimitives.WriteInt32BigEndian(inf, 5);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(inf.AsSpan(4), double.PositiveInfinity);
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.DataPath(inf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_path_boolean_accepts_the_real_controls_ff_true_encoding()
|
||||
{
|
||||
Lsv2Data.DataPath([0, 0, 0, 11, 0xFF]).ShouldBe(true);
|
||||
Lsv2Data.DataPath([0, 0, 0, 11, 0]).ShouldBe(false);
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.DataPath([0, 0, 0, 11, 2]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Numeric_counts_are_bytes_even_when_no_adjacent_area_caps_them()
|
||||
{
|
||||
foreach (var length in new[] { 120, 124 })
|
||||
{
|
||||
var bytes = new byte[length];
|
||||
SimulatedControl.LayoutBytes(10).CopyTo(bytes, 0);
|
||||
var layout = Lsv2Data.Layout(bytes);
|
||||
foreach (var area in new[] { HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord, HeidenhainPlcArea.OutputWord })
|
||||
layout.Region(area)!.Bytes.ShouldBe(10, area.ToString());
|
||||
layout.Region(HeidenhainPlcArea.String)!.Bytes.ShouldBe(160);
|
||||
layout.Region(HeidenhainPlcArea.Word)!.Base.ShouldBe(1000);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Memory_range_uses_byte_capacity_and_element_index()
|
||||
{
|
||||
var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 1, layout).ShouldBe(new HeidenhainMemoryRange(1004, 4));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 2, layout));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 9, layout).Address.ShouldBe(1009);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 10, layout));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 4, layout).Address.ShouldBe(1008);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 5, layout));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 4, layout).Address.ShouldBe(3008);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 5, layout));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 4, layout).Address.ShouldBe(4008);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 5, layout));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 0, null));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 0xFFFF_FFFFL, layout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Memory_and_runtime_coercion_cannot_truncate_or_wrap()
|
||||
{
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Dword, [0xFE, 0xFF, 0xFF, 0xFF], DriverDataType.Int64).ShouldBe(-2L);
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputWord, [0xFF, 0xFF], DriverDataType.Int32).ShouldBe(65535);
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Word, [0xFF, 0xFF], DriverDataType.Int16).ShouldBe((short)-1);
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [1], DriverDataType.Boolean).ShouldBe(true);
|
||||
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [0xFF], DriverDataType.Boolean));
|
||||
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Dword, new byte[3], DriverDataType.Int64));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(1.5, DriverDataType.Int32));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(32768, DriverDataType.Int16));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(1, DriverDataType.Boolean));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(1, DriverDataType.String));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce(double.NaN, DriverDataType.Float64));
|
||||
Should.Throw<HeidenhainTypeMismatchException>(() => HeidenhainBindingCodec.Coerce("x", DriverDataType.Int64));
|
||||
HeidenhainBindingCodec.Coerce(2.0, DriverDataType.Int64).ShouldBe(2L);
|
||||
HeidenhainBindingCodec.Coerce(7, DriverDataType.Float64).ShouldBe(7.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle49")]
|
||||
[InlineData("hermle59")]
|
||||
public void Captured_layouts_reject_numeric_reads_at_the_next_advertised_area(string machine)
|
||||
{
|
||||
var f = Fixtures.Load("itnc530-inspect.properties");
|
||||
var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"]));
|
||||
foreach (var (area, index) in new[] { (HeidenhainPlcArea.Byte, 10000L), (HeidenhainPlcArea.Word, 5000L), (HeidenhainPlcArea.Dword, 2500L), (HeidenhainPlcArea.InputWord, 500L), (HeidenhainPlcArea.OutputWord, 500L) })
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(area, index, layout), $"{machine} {area}:{index}");
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 9999, layout).Address.ShouldBe(10063);
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 4999, layout).Address.ShouldBe(10062);
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.Dword, 2499, layout).Address.ShouldBe(10060);
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 499, layout).Address.ShouldBe(11062);
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputWord, 499, layout).Address.ShouldBe(12062);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The payload-aware outbound allowlist (ported from the reference <c>rejectsEveryWriteAndElevatedRole</c> /
|
||||
/// <c>validatesSelectorsMemoryAndPathsBeforeTransmission</c>), plus proof that a refused request never puts a
|
||||
/// single byte on the wire and that the driver has no write surface at all.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Lsv2ReadOnlyPolicyTests
|
||||
{
|
||||
public static TheoryData<string> WriteAndControlCommands() =>
|
||||
[
|
||||
// file / PLC / parameter / buffer writes and transfers, logout, file reads, key & scope operations,
|
||||
// and response mnemonics that must never be sent as requests.
|
||||
"C_CC", "C_FL", "C_MB", "C_DC", "C_DD", "C_FD", "C_FC", "C_PR", "C_EK", "C_LK", "C_MC", "C_TP",
|
||||
"A_LO", "R_MC", "R_FL", "R_DI", "R_FI", "R_DR", "R_ST", "R_TP", "R_OC", "T_OK", "T_ER", "S_RI", "XXXX",
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WriteAndControlCommands))]
|
||||
public void Every_non_allowlisted_command_is_refused(string command)
|
||||
{
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check(command, []));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check(command, [0, 22]));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2Codec.Frame(command, []));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PLCDEBUG")]
|
||||
[InlineData("MONITOR")]
|
||||
[InlineData("FILE")]
|
||||
[InlineData("DIAGNOSTICS")]
|
||||
[InlineData("DNC\0PLCDEBUG")]
|
||||
[InlineData("inspect")]
|
||||
[InlineData("")]
|
||||
public void Elevated_or_unknown_roles_are_refused(string role) =>
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.LoginPayload(role, ""));
|
||||
|
||||
[Fact]
|
||||
public void Login_payloads_are_checked_for_embedded_nul_and_role_smuggling()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => Lsv2ReadOnlyPolicy.LoginPayload("DNC", "x\0y"));
|
||||
// a hand-built payload that names an elevated role after a permitted one
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("A_LG", "PLCDEBUG\0"u8.ToArray()));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("A_LG", "INSPECT"u8.ToArray()));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("A_LG", "INSPECT\0a\0b\0"u8.ToArray()));
|
||||
Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", "").ShouldBe("INSPECT\0"u8.ToArray());
|
||||
Lsv2ReadOnlyPolicy.LoginPayload("DATA", "pw").ShouldBe("DATA\0pw\0"u8.ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(21)]
|
||||
[InlineData(29)]
|
||||
[InlineData(35)]
|
||||
[InlineData(50)]
|
||||
[InlineData(52)]
|
||||
[InlineData(0xFFFF)]
|
||||
public void Runtime_selectors_outside_the_allowlist_are_refused(int selector) =>
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.RuntimePayload(selector));
|
||||
|
||||
[Fact]
|
||||
public void Allowlisted_selectors_pass_and_payload_shape_is_enforced()
|
||||
{
|
||||
foreach (var s in new[] { 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51 })
|
||||
Lsv2ReadOnlyPolicy.RuntimePayload(s).Length.ShouldBe(2);
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_RI", [0, 23, 0]));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_VR", [0]));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_PR", [1, 2]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Memory_reads_are_bounded_and_cannot_wrap()
|
||||
{
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_MB", new byte[5])); // zero count
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.MemoryPayload(0, 248));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.MemoryPayload(-1, 1));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.MemoryPayload(0xFFFF_FFFF, 2)); // wraps
|
||||
Lsv2ReadOnlyPolicy.MemoryPayload(0xFFFF_FFFF, 1).ShouldBe(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 1 });
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_MB", new byte[6]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_paths_are_validated_before_transmission()
|
||||
{
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.DataPathPayload("/a\0/b"));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.DataPathPayload("/a/€"));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.DataPathPayload(new string('/', 244)));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.DataPathPayload(" "));
|
||||
Should.Throw<Lsv2PolicyException>(() => Lsv2ReadOnlyPolicy.Check("R_DP", [0, 0, 0, 1, 65, 0])); // nonzero prefix
|
||||
Lsv2ReadOnlyPolicy.DataPathPayload("/PLC/memory/W/2").ShouldBe("\0\0\0\0\\PLC\\memory\\W\\2\0"u8.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_request_never_reaches_the_socket()
|
||||
{
|
||||
var received = 0;
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
while (await c.ReadByteAsync(ct) >= 0) Interlocked.Increment(ref received);
|
||||
});
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
await client.OpenAsync(TestContext.Current.CancellationToken);
|
||||
foreach (var cmd in new[] { "C_CC", "C_MB", "A_LO", "R_FL" })
|
||||
await Should.ThrowAsync<Lsv2PolicyException>(() => client.ExchangeAsync(cmd, [], "T_OK", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<Lsv2PolicyException>(() => client.ExchangeAsync("R_RI", [0, 29], "S_RI", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<Lsv2PolicyException>(() => client.ExchangeAsync("A_LG", "PLCDEBUG\0"u8.ToArray(), "T_OK", TestContext.Current.CancellationToken));
|
||||
client.RequestsSent.ShouldBe(0);
|
||||
client.IsClosed.ShouldBeFalse(); // a policy refusal is not a transport failure
|
||||
client.Close();
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
Volatile.Read(ref received).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_public_surface_has_no_raw_command_api()
|
||||
{
|
||||
var methods = typeof(Lsv2Client).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => !m.IsSpecialName).Select(m => m.Name).OrderBy(n => n).ToArray();
|
||||
methods.ShouldBe(new[]
|
||||
{
|
||||
"Close", "DisposeAsync", "LoginAsync", "OpenAsync", "ReadDataPathAsync", "ReadMemoryAsync",
|
||||
"ReadParametersAsync", "ReadRuntimeAsync", "ReadVersionAsync",
|
||||
});
|
||||
typeof(Lsv2Client).GetMethod("ExchangeAsync", BindingFlags.Public | BindingFlags.Instance).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_has_no_write_capability()
|
||||
{
|
||||
typeof(IWritable).IsAssignableFrom(typeof(HeidenhainDriver)).ShouldBeFalse();
|
||||
typeof(HeidenhainDriver).GetMethods().ShouldNotContain(m => m.Name.Contains("Write", StringComparison.Ordinal));
|
||||
Lsv2ReadOnlyPolicy.Commands.ShouldBe(new[] { "A_LG", "R_VR", "R_PR", "R_RI", "R_MB", "R_DP" }, ignoreOrder: true);
|
||||
Lsv2ReadOnlyPolicy.Roles.ShouldBe(new[] { "INSPECT", "DNC", "DATA" }, ignoreOrder: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
/// <summary>Framing/transport regression ported from the reference <c>Lsv2TransportTest</c> (literal frames, independent of the encoder).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Lsv2TransportTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task<Lsv2Client> OpenAsync(Lsv2Simulator sim, int timeoutMs = 1000)
|
||||
{
|
||||
var c = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromMilliseconds(timeoutMs));
|
||||
await c.OpenAsync(Ct);
|
||||
return c;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Literal_runtime_request_and_byte_fragmented_response()
|
||||
{
|
||||
byte[]? seen = null;
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
var req = await c.ReadRequestAsync(ct);
|
||||
seen = SimConnection.Frame(req!.Command, req.Payload);
|
||||
await c.WriteRawAsync(Convert.FromHexString("00000002535f52490003"), byteByByte: true, ct);
|
||||
});
|
||||
await using var client = await OpenAsync(sim);
|
||||
(await client.ReadRuntimeAsync(23, Ct)).ShouldBe(new byte[] { 0, 3 });
|
||||
seen.ShouldBe(Convert.FromHexString("00000002525f52490017"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Controller_refusal_preserves_code_and_stream_sync()
|
||||
{
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
await c.ReadRequestAsync(ct);
|
||||
await c.ReplyAsync("T_ER", [1, 23], ct);
|
||||
await c.ReadRequestAsync(ct);
|
||||
await c.ReplyAsync("S_VR", [65, 0], ct);
|
||||
});
|
||||
await using var client = await OpenAsync(sim);
|
||||
var refusal = await Should.ThrowAsync<Lsv2RefusalException>(() => client.ReadRuntimeAsync(23, Ct));
|
||||
refusal.Code.ShouldBe(23);
|
||||
client.IsClosed.ShouldBeFalse();
|
||||
(await client.ReadVersionAsync(Ct)).ShouldBe(new byte[] { 65, 0 });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("00010000535f5249")] // oversized length: rejected before allocation
|
||||
[InlineData("00000000535f5652")] // unexpected response type (S_VR for an R_RI)
|
||||
[InlineData("00000002535f524900")] // partial body then EOF
|
||||
[InlineData("00000003545f455201")] // malformed refusal (3-byte T_ER)
|
||||
[InlineData("00000001545f4f4b00")] // T_OK with a body
|
||||
public async Task Bad_frames_close_the_client_and_later_requests_fail(string hex)
|
||||
{
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
await c.ReadRequestAsync(ct);
|
||||
await c.WriteRawAsync(Convert.FromHexString(hex), byteByByte: false, ct);
|
||||
if (hex.EndsWith("00", StringComparison.Ordinal) && hex.Length == 18) c.Close();
|
||||
await Task.Delay(2000, ct);
|
||||
});
|
||||
await using var client = await OpenAsync(sim);
|
||||
var req = hex.Contains("545f4f4b", StringComparison.Ordinal)
|
||||
? client.LoginAsync("INSPECT", "", Ct)
|
||||
: client.ReadRuntimeAsync(23, Ct);
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => req);
|
||||
client.IsClosed.ShouldBeTrue();
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => client.ReadVersionAsync(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_interrupts_a_blocked_read_without_waiting_for_the_gate()
|
||||
{
|
||||
var requested = new TaskCompletionSource();
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
await c.ReadRequestAsync(ct);
|
||||
requested.TrySetResult();
|
||||
await c.ReadByteAsync(ct); // never answers
|
||||
});
|
||||
await using var client = await OpenAsync(sim, timeoutMs: 10_000);
|
||||
var pending = client.ReadRuntimeAsync(23, Ct);
|
||||
await requested.Task.WaitAsync(TimeSpan.FromSeconds(2), Ct);
|
||||
var sw = Stopwatch.StartNew();
|
||||
client.Close();
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => pending.WaitAsync(TimeSpan.FromSeconds(2), Ct));
|
||||
sw.ElapsedMilliseconds.ShouldBeLessThan(2000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Trickle_cannot_reset_the_transaction_deadline()
|
||||
{
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
await c.ReadRequestAsync(ct);
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
await c.WriteRawAsync([0], byteByByte: false, ct);
|
||||
await Task.Delay(40, ct);
|
||||
}
|
||||
});
|
||||
await using var client = await OpenAsync(sim, timeoutMs: 120);
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => client.ReadRuntimeAsync(23, Ct));
|
||||
sw.ElapsedMilliseconds.ShouldBeLessThan(1000);
|
||||
client.IsClosed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transactions_are_serialized_one_frame_at_a_time()
|
||||
{
|
||||
var concurrent = 0;
|
||||
var maxConcurrent = 0;
|
||||
await using var sim = Lsv2Simulator.WithScript(async (c, ct) =>
|
||||
{
|
||||
while (await c.ReadRequestAsync(ct) is not null)
|
||||
{
|
||||
var now = Interlocked.Increment(ref concurrent);
|
||||
maxConcurrent = Math.Max(maxConcurrent, now);
|
||||
await Task.Delay(10, ct);
|
||||
Interlocked.Decrement(ref concurrent);
|
||||
await c.ReplyAsync("S_RI", [0, 1], ct);
|
||||
}
|
||||
});
|
||||
await using var client = await OpenAsync(sim);
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => client.ReadRuntimeAsync(23, Ct)));
|
||||
maxConcurrent.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_to_a_closed_port_is_a_transport_failure()
|
||||
{
|
||||
int port;
|
||||
await using (var sim = Lsv2Simulator.WithScript((_, _) => Task.CompletedTask)) port = sim.Port;
|
||||
var client = new Lsv2Client("127.0.0.1", port, TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<Lsv2TransportException>(() => client.OpenAsync(Ct));
|
||||
client.IsClosed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
/// <summary>One request frame as the simulated control received it.</summary>
|
||||
public sealed record Lsv2Request(string Command, byte[] Payload)
|
||||
{
|
||||
/// <summary>The payload decoded as Latin-1 (for login/path assertions).</summary>
|
||||
public string Text => Encoding.Latin1.GetString(Payload);
|
||||
|
||||
/// <summary>The R_RI selector.</summary>
|
||||
public int Selector => BinaryPrimitives.ReadUInt16BigEndian(Payload);
|
||||
}
|
||||
|
||||
/// <summary>A scripted connection to the simulator.</summary>
|
||||
public sealed class SimConnection(TcpClient client)
|
||||
{
|
||||
private readonly NetworkStream _stream = client.GetStream();
|
||||
|
||||
/// <summary>Reads one complete request frame, or null on EOF.</summary>
|
||||
public async Task<Lsv2Request?> ReadRequestAsync(CancellationToken ct)
|
||||
{
|
||||
var header = new byte[8];
|
||||
var read = 0;
|
||||
while (read < 8)
|
||||
{
|
||||
var n = await _stream.ReadAsync(header.AsMemory(read), ct).ConfigureAwait(false);
|
||||
if (n == 0) return read == 0 ? null : throw new EndOfStreamException();
|
||||
read += n;
|
||||
}
|
||||
|
||||
var length = (int)BinaryPrimitives.ReadUInt32BigEndian(header);
|
||||
var payload = new byte[length];
|
||||
await _stream.ReadExactlyAsync(payload, ct).ConfigureAwait(false);
|
||||
return new Lsv2Request(Encoding.ASCII.GetString(header, 4, 4), payload);
|
||||
}
|
||||
|
||||
/// <summary>Writes one well-formed reply frame.</summary>
|
||||
public Task ReplyAsync(string command, byte[] payload, CancellationToken ct = default) =>
|
||||
WriteRawAsync(Frame(command, payload), byteByByte: false, ct);
|
||||
|
||||
/// <summary>Writes raw bytes, optionally one byte per write (TCP fragmentation).</summary>
|
||||
public async Task WriteRawAsync(byte[] bytes, bool byteByByte, CancellationToken ct = default)
|
||||
{
|
||||
if (!byteByByte)
|
||||
{
|
||||
await _stream.WriteAsync(bytes, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var b in bytes)
|
||||
{
|
||||
await _stream.WriteAsync(new[] { b }, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
await Task.Delay(1, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads one raw byte (blocks until the peer sends or closes).</summary>
|
||||
public async Task<int> ReadByteAsync(CancellationToken ct)
|
||||
{
|
||||
var b = new byte[1];
|
||||
return await _stream.ReadAsync(b, ct).ConfigureAwait(false) == 0 ? -1 : b[0];
|
||||
}
|
||||
|
||||
/// <summary>Closes the connection.</summary>
|
||||
public void Close() => client.Dispose();
|
||||
|
||||
/// <summary>Builds a frame independently of the driver's encoder.</summary>
|
||||
public static byte[] Frame(string command, byte[] payload)
|
||||
{
|
||||
var f = new byte[8 + payload.Length];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(f, (uint)payload.Length);
|
||||
Encoding.ASCII.GetBytes(command, f.AsSpan(4, 4));
|
||||
payload.CopyTo(f, 8);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-process LSV2 peer on a loopback port. Either runs a per-connection <see cref="Script"/> (framing tests)
|
||||
/// or serves a <see cref="SimulatedControl"/> model. Records every request it receives on any connection,
|
||||
/// so a test can prove that only allowlisted telegrams ever left the driver.
|
||||
/// </summary>
|
||||
public sealed class Lsv2Simulator : IAsyncDisposable
|
||||
{
|
||||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Func<SimConnection, CancellationToken, Task> _script;
|
||||
private readonly ConcurrentQueue<Lsv2Request> _requests = new();
|
||||
private readonly ConcurrentBag<TcpClient> _clients = new();
|
||||
private readonly Task _accept;
|
||||
private readonly ConcurrentQueue<Exception> _faults = new();
|
||||
private volatile bool _offline;
|
||||
private int _connections;
|
||||
|
||||
private Lsv2Simulator(Func<SimConnection, CancellationToken, Task> script)
|
||||
{
|
||||
_script = script;
|
||||
_listener.Start();
|
||||
_accept = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
/// <summary>A simulator running a raw per-connection script.</summary>
|
||||
public static Lsv2Simulator WithScript(Func<SimConnection, CancellationToken, Task> script) => new(script);
|
||||
|
||||
/// <summary>A simulator serving a control model (one model shared by all connections).</summary>
|
||||
public static Lsv2Simulator WithControl(SimulatedControl control)
|
||||
{
|
||||
Lsv2Simulator? sim = null;
|
||||
sim = new Lsv2Simulator(async (c, ct) =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var req = await c.ReadRequestAsync(ct).ConfigureAwait(false);
|
||||
if (req is null) return;
|
||||
sim!._requests.Enqueue(req);
|
||||
var reply = control.Handle(req);
|
||||
switch (reply.Kind)
|
||||
{
|
||||
case SimReplyKind.Frame:
|
||||
await c.ReplyAsync(reply.Command!, reply.Payload!, ct).ConfigureAwait(false);
|
||||
break;
|
||||
case SimReplyKind.Drop:
|
||||
c.Close();
|
||||
return;
|
||||
case SimReplyKind.Stall:
|
||||
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return sim;
|
||||
}
|
||||
|
||||
/// <summary>The loopback port.</summary>
|
||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>Every request received (model mode), in arrival order.</summary>
|
||||
public IReadOnlyList<Lsv2Request> Requests => _requests.ToArray();
|
||||
|
||||
/// <summary>Accepted connection count.</summary>
|
||||
public int Connections => Volatile.Read(ref _connections);
|
||||
|
||||
/// <summary>Exceptions a script threw (tests assert this is empty).</summary>
|
||||
public IReadOnlyList<Exception> Faults => _faults.ToArray();
|
||||
|
||||
/// <summary>Simulates a network outage: drops every live connection and refuses (closes) new ones.</summary>
|
||||
public void GoOffline()
|
||||
{
|
||||
_offline = true;
|
||||
foreach (var c in _clients) c.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Ends the outage.</summary>
|
||||
public void GoOnline() => _offline = false;
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try { client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); }
|
||||
catch { return; }
|
||||
if (_offline)
|
||||
{
|
||||
client.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _connections);
|
||||
_clients.Add(client);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await _script(new SimConnection(client), _cts.Token).ConfigureAwait(false); }
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or SocketException or EndOfStreamException) { }
|
||||
catch (Exception ex) { _faults.Enqueue(ex); }
|
||||
finally { client.Dispose(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
_listener.Stop();
|
||||
foreach (var c in _clients) c.Dispose();
|
||||
try { await _accept.ConfigureAwait(false); } catch { /* stopped */ }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
public enum SimReplyKind { Frame, Drop, Stall }
|
||||
|
||||
/// <summary>What the simulated control does with one request.</summary>
|
||||
public sealed record SimReply(SimReplyKind Kind, string? Command = null, byte[]? Payload = null)
|
||||
{
|
||||
public static SimReply Ok(string command, byte[] payload) => new(SimReplyKind.Frame, command, payload);
|
||||
public static SimReply Refuse(int code, int type = 1) => new(SimReplyKind.Frame, "T_ER", [(byte)type, (byte)code]);
|
||||
public static SimReply Drop() => new(SimReplyKind.Drop);
|
||||
public static SimReply Stall() => new(SimReplyKind.Stall);
|
||||
|
||||
/// <summary>Parses a reference fixture value <c>S_RI:0004</c> / <c>T_ER:0117</c>.</summary>
|
||||
public static SimReply FromFixture(string value)
|
||||
{
|
||||
var parts = value.Split(':', 2);
|
||||
return Ok(parts[0], Convert.FromHexString(parts[1]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A small, mutable model of an iTNC 530 speaking the six allowlisted LSV2 requests. Anything else is
|
||||
/// recorded as a <see cref="Violations">violation</see> and refused — the driver must never produce one.
|
||||
/// </summary>
|
||||
public sealed class SimulatedControl
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _violations = new();
|
||||
|
||||
public byte[] Version { get; set; } = Encoding.Latin1.GetBytes("iTNC 530\0NC 340490 04\0PLC\0OPT\0");
|
||||
public byte[]? Layout { get; set; } = LayoutBytes(10);
|
||||
public Func<string, string, SimReply?>? Login { get; set; }
|
||||
public ConcurrentDictionary<int, Func<SimReply>> Runtime { get; } = new();
|
||||
public ConcurrentDictionary<string, SimReply> DataPaths { get; } = new(StringComparer.Ordinal);
|
||||
public Func<long, int, SimReply>? Memory { get; set; }
|
||||
public List<(string Role, string Password)> Logins { get; } = [];
|
||||
|
||||
/// <summary>Requests the control refused as not-allowlisted (must stay empty).</summary>
|
||||
public IReadOnlyList<string> Violations => _violations.ToArray();
|
||||
|
||||
/// <summary>Optional hook to override any reply (fault injection).</summary>
|
||||
public Func<Lsv2Request, SimReply?>? Intercept { get; set; }
|
||||
|
||||
public SimReply Handle(Lsv2Request req)
|
||||
{
|
||||
if (Intercept?.Invoke(req) is { } forced) return forced;
|
||||
switch (req.Command)
|
||||
{
|
||||
case "A_LG":
|
||||
{
|
||||
var fields = req.Text.Split('\0');
|
||||
var role = fields[0];
|
||||
var password = fields.Length > 2 ? fields[1] : "";
|
||||
lock (Logins) Logins.Add((role, password));
|
||||
return Login?.Invoke(role, password) ?? SimReply.Ok("T_OK", []);
|
||||
}
|
||||
case "R_VR" when req.Payload.Length == 0:
|
||||
return SimReply.Ok("S_VR", Version);
|
||||
case "R_PR" when req.Payload.Length == 0:
|
||||
return Layout is null ? SimReply.Refuse(24) : SimReply.Ok("S_PR", Layout);
|
||||
case "R_RI" when req.Payload.Length == 2:
|
||||
return Runtime.TryGetValue(req.Selector, out var f) ? f() : SimReply.Refuse(24);
|
||||
case "R_MB" when req.Payload.Length == 5:
|
||||
var address = BinaryPrimitives.ReadUInt32BigEndian(req.Payload);
|
||||
return Memory?.Invoke(address, req.Payload[4]) ?? SimReply.Refuse(23);
|
||||
case "R_DP" when req.Payload.Length > 5:
|
||||
{
|
||||
var path = Encoding.Latin1.GetString(req.Payload, 4, req.Payload.Length - 5).Replace('\\', '/');
|
||||
return DataPaths.TryGetValue(path, out var r) ? r : SimReply.Refuse(24);
|
||||
}
|
||||
default:
|
||||
_violations.Enqueue(req.Command);
|
||||
return SimReply.Refuse(22);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The reference test layout: words at 1000 with the given byte count, strings at 2000 (width 16), IW 3000, OW 4000.</summary>
|
||||
public static byte[] LayoutBytes(int wordBytes)
|
||||
{
|
||||
var b = new byte[120];
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(40), 1000);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(44), wordBytes);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(48), 2000);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(52), 10);
|
||||
b[56] = 16;
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(64), 3000);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(68), 10);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(72), 4000);
|
||||
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(76), 10);
|
||||
return b;
|
||||
}
|
||||
|
||||
/// <summary>Two-byte uint16be runtime reply.</summary>
|
||||
public static SimReply U16(int v)
|
||||
{
|
||||
var p = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(p, (ushort)v);
|
||||
return SimReply.Ok("S_RI", p);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Loads the ported Java <c>.properties</c> golden vectors.</summary>
|
||||
public static class Fixtures
|
||||
{
|
||||
public static IReadOnlyDictionary<string, string> Load(string name)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
|
||||
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
if (line.Length == 0 || line[0] == '#') continue;
|
||||
var eq = line.IndexOf('=');
|
||||
map[line[..eq]] = line[(eq + 1)..];
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
|
||||
|
||||
internal static class TestTags
|
||||
{
|
||||
/// <summary>A TagConfig blob for an address (array flag and dataType filled in the way the editor writes them).</summary>
|
||||
public static string Config(string address, string? dataType = null)
|
||||
{
|
||||
var o = new Dictionary<string, object> { ["address"] = address };
|
||||
if (dataType is not null) o["dataType"] = dataType;
|
||||
if (HeidenhainCatalog.TryGet(address, out var e) && e.IsArray) o["isArray"] = true;
|
||||
return JsonSerializer.Serialize(o);
|
||||
}
|
||||
|
||||
public static HeidenhainTagDefinition Def(string address, string? dataType = null, string? rawPath = null)
|
||||
{
|
||||
if (!HeidenhainTagDefinitionFactory.TryFromTagConfig(rawPath ?? "Plant/H/dev1/" + address, "dev1", Config(address, dataType), out var d, out var error))
|
||||
throw new InvalidOperationException(error);
|
||||
return d;
|
||||
}
|
||||
|
||||
public static RawTagEntry Raw(string address, string? dataType = null, string device = "dev1") =>
|
||||
new($"Plant/H/{device}/{address}", Config(address, dataType), false, device);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- OTOPCUA0001: this suite invokes driver capability methods DIRECTLY to exercise wire-level behaviour
|
||||
against the in-process LSV2 simulator — the analyzer's documented intentional case. -->
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Golden vectors ported verbatim from the ignitionoee v0.20.3 Java test resources (real iTNC 530
|
||||
replies captured 2026-11-03 — reference evidence, not proof of this port). -->
|
||||
<ItemGroup>
|
||||
<Content Include="Fixtures\**\*.properties">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user