This commit is contained in:
+263
@@ -0,0 +1,263 @@
|
||||
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>
|
||||
/// Control-family classification and the per-family gates, per pyLSV2 (MIT). Only the iTNC 530 rows are
|
||||
/// hardware-backed; every other profile is built from what pyLSV2 records (see <see cref="ControlProfiles"/>).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainControlFamilyTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
public static TheoryData<string> Profiles()
|
||||
{
|
||||
var d = new TheoryData<string>();
|
||||
foreach (var p in ControlProfiles.All) d.Add(p.Name);
|
||||
return d;
|
||||
}
|
||||
|
||||
private static ControlProfile Profile(string name) => ControlProfiles.All.Single(p => p.Name == name);
|
||||
|
||||
[Theory]
|
||||
// per pyLSV2 (MIT) dat_cls.py:VersionInfo.control — spaces stripped, case-insensitive prefix.
|
||||
[InlineData("iTNC530", HeidenhainControlFamily.ITnc530)]
|
||||
[InlineData("iTNC 530", HeidenhainControlFamily.ITnc530)]
|
||||
[InlineData("iTNC530 Programmierplatz", HeidenhainControlFamily.ITnc530)]
|
||||
[InlineData("ITNC530", HeidenhainControlFamily.ITnc530)]
|
||||
[InlineData("TNC640", HeidenhainControlFamily.Tnc)]
|
||||
[InlineData("TNC 640", HeidenhainControlFamily.Tnc)]
|
||||
[InlineData("TNC620", HeidenhainControlFamily.Tnc)]
|
||||
[InlineData("TNC320", HeidenhainControlFamily.Tnc)]
|
||||
[InlineData("tnc128", HeidenhainControlFamily.Tnc)]
|
||||
[InlineData("TNC7", HeidenhainControlFamily.Tnc7)]
|
||||
[InlineData("TNC 7", HeidenhainControlFamily.Tnc7)]
|
||||
[InlineData("CNCpilot640", HeidenhainControlFamily.Lathe)]
|
||||
[InlineData("CNC PILOT 640", HeidenhainControlFamily.Lathe)]
|
||||
[InlineData("MANUALplus620", HeidenhainControlFamily.Lathe)]
|
||||
[InlineData("MillPlusIT V600", HeidenhainControlFamily.MillPlus)]
|
||||
[InlineData("TNC 426", HeidenhainControlFamily.LegacyTnc)]
|
||||
[InlineData("TNC430", HeidenhainControlFamily.LegacyTnc)]
|
||||
[InlineData("TNC", HeidenhainControlFamily.Unknown)]
|
||||
[InlineData("TNC 530", HeidenhainControlFamily.Unknown)] // not an iTNC: no "i"
|
||||
[InlineData("Anilam 6000i", HeidenhainControlFamily.Unknown)]
|
||||
[InlineData("", HeidenhainControlFamily.Unknown)]
|
||||
[InlineData(" ", HeidenhainControlFamily.Unknown)]
|
||||
[InlineData(null, HeidenhainControlFamily.Unknown)]
|
||||
public void Model_string_classifies_to_its_family(string? model, HeidenhainControlFamily expected) =>
|
||||
HeidenhainControlFamilies.Classify(model).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Only_the_itnc_530_supports_data_paths()
|
||||
{
|
||||
foreach (var f in Enum.GetValues<HeidenhainControlFamily>())
|
||||
HeidenhainControlFamilies.SupportsDataPath(f).ShouldBe(f == HeidenhainControlFamily.ITnc530, f.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_is_a_fixed_identity_string_leaf()
|
||||
{
|
||||
HeidenhainCatalog.TryGet("Controller/Family", out var e).ShouldBeTrue();
|
||||
e.DataType.ShouldBe(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String);
|
||||
e.IsArray.ShouldBeFalse();
|
||||
e.Feature.ShouldBe(HeidenhainCatalog.IdentityFeature);
|
||||
e.Group.ShouldBe(HeidenhainPollGroup.Slow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_is_derived_so_identity_equality_is_unchanged()
|
||||
{
|
||||
var a = new Lsv2Versions("iTNC530", "340490 04 SP10", "PLC", "OPT");
|
||||
a.ShouldBe(new Lsv2Versions("iTNC530", "340490 04 SP10", "PLC", "OPT"));
|
||||
a.Family.ShouldBe(HeidenhainControlFamily.ITnc530);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Profiles))]
|
||||
public void Version_reply_of_every_family_decodes_in_both_field_counts(string name)
|
||||
{
|
||||
var p = Profile(name);
|
||||
foreach (var five in new[] { false, true })
|
||||
{
|
||||
var v = Lsv2Data.Versions(ControlProfiles.VersionReply(p with { FiveFieldVersion = five }));
|
||||
v.Model.ShouldBe(p.Model);
|
||||
v.Nc.ShouldBe(p.Nc);
|
||||
v.Options.ShouldBe("%0000111100000111"); // SPLC is skipped, never relabelled as options
|
||||
v.Family.ShouldBe(p.Family);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Profiles))]
|
||||
public async Task Family_leaf_publishes_the_classified_family(string name)
|
||||
{
|
||||
var p = Profile(name);
|
||||
await using var h = await Harness.OpenAsync(ControlProfiles.Build(p), Std("Controller/Family", "Controller/Model", "Capabilities/Identity"));
|
||||
var s = await h.ReadAsync();
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
s[0].Value.ShouldBe(p.Family.ToString());
|
||||
s[0].Value.ShouldBeOfType<string>();
|
||||
s[1].Value.ShouldBe(p.Model); // the raw model string is never normalised
|
||||
s[2].Value.ShouldBe(HeidenhainCatalog.States.Supported);
|
||||
h.Session.Identity!.Family.ShouldBe(p.Family);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Profiles))]
|
||||
public async Task Data_path_is_iTNC_only_and_a_non_iTNC_never_receives_the_DATA_login(string name)
|
||||
{
|
||||
var p = Profile(name);
|
||||
var tags = new List<HeidenhainTagDefinition> { TestTags.Def("DATA:/x", "Int64"), TestTags.Def("Capabilities/DataLogin") };
|
||||
await using var h = await Harness.OpenAsync(ControlProfiles.Build(p), tags, telemetry: false, data: true);
|
||||
var first = await h.ReadAsync();
|
||||
var second = await h.ReadAsync();
|
||||
if (p.Family == HeidenhainControlFamily.ITnc530)
|
||||
{
|
||||
first[0].Value.ShouldBe(42L);
|
||||
first[1].Value.ShouldBe(HeidenhainCatalog.States.Supported);
|
||||
h.Control.Logins.Select(l => l.Role).ShouldBe(["INSPECT", "DATA"]);
|
||||
h.Sim.Requests.Count(r => r.Command == "R_DP").ShouldBe(2);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var s in new[] { first, second })
|
||||
{
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
s[1].Value.ShouldBe(HeidenhainCatalog.States.Unsupported);
|
||||
}
|
||||
|
||||
h.Control.Logins.Select(l => l.Role).ShouldBe(["INSPECT"]);
|
||||
h.Sim.Requests.ShouldNotContain(r => r.Command == "R_DP");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Profiles))]
|
||||
public async Task Full_catalog_on_every_family_stays_inside_the_read_only_set(string name)
|
||||
{
|
||||
var p = Profile(name);
|
||||
var c = ControlProfiles.Build(p);
|
||||
var tags = HeidenhainCatalog.Entries.Select(e => TestTags.Def(e.Address)).ToList();
|
||||
tags.Add(TestTags.Def("DATA:/x", "Int64"));
|
||||
tags.Add(TestTags.Def("PLC:WORD:1", "Int32"));
|
||||
await using var h = await Harness.OpenAsync(c, tags, timers: true, data: true);
|
||||
await h.ReadAsync();
|
||||
await h.ReadAsync();
|
||||
h.Sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(Lsv2ReadOnlyPolicy.Commands);
|
||||
h.Control.Logins.Select(l => l.Role).Distinct().ShouldBeSubsetOf(Lsv2ReadOnlyPolicy.Roles);
|
||||
if (p.Family != HeidenhainControlFamily.ITnc530)
|
||||
{
|
||||
h.Control.Logins.ShouldNotContain(l => l.Role == "DATA");
|
||||
h.Sim.Requests.ShouldNotContain(r => r.Command == "R_DP");
|
||||
}
|
||||
|
||||
// A selector the family refuses (T_ER_UNKNOWN_TELE) is asked once per session, then suppressed.
|
||||
foreach (var s in new[] { 51, 27, 30, 31, 32, 33, 34 })
|
||||
if (p.Selectors != SimSelectorBehaviour.All)
|
||||
h.Sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == s).ShouldBe(1, $"selector {s}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Tnc_640_observed_selectors_degrade_per_feature(bool dncGranted)
|
||||
{
|
||||
// per pyLSV2 (MIT) docs/protocol.rst — TNC 640 NC 340590 10 SP2: 27/28/51 + 30–34 unknown telegram; 25/26 need DNC.
|
||||
var c = ControlProfiles.Build(ControlProfiles.Tnc640);
|
||||
if (!dncGranted) c.Login = (role, _) => role == "DNC" ? SimReply.Refuse(23) : SimReply.Ok("T_OK", []);
|
||||
var tags = Std("Controller/Family", "Status/ExecutionState", "Status/ProgramState", "Overrides/Feed", "Program/Line", "Axes/Names",
|
||||
"Tool/Number", "Alarms/Count", "Timers/NcUptime",
|
||||
"Capabilities/Tool", "Capabilities/Alarms", "Capabilities/Timer_NcUptime", "Capabilities/Overrides", "Capabilities/ProgramState");
|
||||
await using var h = await Harness.OpenAsync(c, tags, timers: true);
|
||||
var s = await h.ReadAsync();
|
||||
s[0].Value.ShouldBe("Tnc");
|
||||
s[1].Value.ShouldBe(4);
|
||||
s[5].Value.ShouldBe(new[] { "X", "Z" });
|
||||
s[6].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
s[7].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
s[8].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
s[9].Value.ShouldBe(HeidenhainCatalog.States.Unsupported);
|
||||
s[10].Value.ShouldBe(HeidenhainCatalog.States.Unsupported);
|
||||
s[11].Value.ShouldBe(HeidenhainCatalog.States.Unsupported);
|
||||
if (dncGranted)
|
||||
{
|
||||
s[2].Value.ShouldBe(7);
|
||||
s[3].Value.ShouldBe(100.0);
|
||||
s[4].Value.ShouldBe(5L);
|
||||
}
|
||||
else
|
||||
{
|
||||
s[2].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
s[3].Status.ShouldBe(HeidenhainStatus.BadUserAccessDenied);
|
||||
s[4].Value.ShouldBe(0L); // served under INSPECT, block number 0 — published raw, not repaired
|
||||
s[12].Value.ShouldBe(HeidenhainCatalog.States.AccessDenied);
|
||||
s[13].Value.ShouldBe(HeidenhainCatalog.States.AccessDenied);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("TNC 640")]
|
||||
[InlineData("CNC PILOT 640")]
|
||||
[InlineData("iTNC 530")]
|
||||
public async Task Plc_layout_is_family_independent(string name)
|
||||
{
|
||||
// per pyLSV2 (MIT) misc.py:decode_system_parameters — the 120/124-byte R_PR layouts carry no family branch,
|
||||
// and client.py:read_plc_memory has no family gate.
|
||||
var c = ControlProfiles.Build(Profile(name));
|
||||
var layout = SimulatedControl.LayoutBytes(10);
|
||||
c.Layout = [.. layout, 0, 0, 0, 0]; // the 124-byte variant
|
||||
c.Memory = (_, n) => SimReply.Ok("S_MB", new byte[n]);
|
||||
var tags = new List<HeidenhainTagDefinition> { TestTags.Def("PLC:WORD:1", "Int32"), TestTags.Def("Capabilities/PlcLayout") };
|
||||
await using var h = await Harness.OpenAsync(c, tags, telemetry: false);
|
||||
var s = await h.ReadAsync();
|
||||
s[0].Status.ShouldBe(HeidenhainStatus.Good);
|
||||
s[1].Value.ShouldBe(HeidenhainCatalog.States.Supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_reports_the_family()
|
||||
{
|
||||
await using var sim = Lsv2Simulator.WithControl(ControlProfiles.Build(ControlProfiles.Tnc640));
|
||||
var json = $$"""{"Devices":[{"Host":"127.0.0.1","Port":{{sim.Port}},"DeviceName":"d"}]}""";
|
||||
var result = await new HeidenhainDriverProbe().ProbeAsync(json, TimeSpan.FromSeconds(3), Ct);
|
||||
result.Ok.ShouldBeTrue(result.Message);
|
||||
(result.Message ?? "").ShouldContain("TNC640 (Tnc) NC 340590 10 SP2");
|
||||
}
|
||||
|
||||
private static List<HeidenhainTagDefinition> Std(params string[] a) => a.Select(x => TestTags.Def(x)).ToList();
|
||||
|
||||
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 Task<IReadOnlyList<HeidenhainSample>> ReadAsync() => Session.ReadBatchAsync(Tags, Ct);
|
||||
|
||||
public static async Task<Harness> OpenAsync(
|
||||
SimulatedControl control, IReadOnlyList<HeidenhainTagDefinition> tags, bool telemetry = true, bool data = false, bool timers = false)
|
||||
{
|
||||
var sim = Lsv2Simulator.WithControl(control);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var settings = new HeidenhainSessionSettings(telemetry, true, data, 64, timers,
|
||||
tags.Any(t => t.Address.Kind == HeidenhainAddressKind.Plc), tags.Any(t => t.Address.Kind == HeidenhainAddressKind.DataPath));
|
||||
var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
return new Harness { Sim = sim, Control = control, Session = session, Tags = tags };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Session.DisposeAsync();
|
||||
Control.Violations.ShouldBeEmpty();
|
||||
await Sim.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -86,6 +86,49 @@ public sealed class HeidenhainLiveIntegrationTests
|
||||
values[0].StatusCode.ShouldBe(HeidenhainStatus.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derived String leaves (family classification, pyLSV2 state names, tool axis name) — no extra wire request;
|
||||
/// each is computed from a reply the session already reads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Real_control_derived_name_leaves()
|
||||
{
|
||||
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("Controller/Family"),
|
||||
TestTags.Raw("Status/ExecutionState"), TestTags.Raw("Status/ExecutionStateName"),
|
||||
TestTags.Raw("Status/ProgramState"), TestTags.Raw("Status/ProgramStateName"),
|
||||
TestTags.Raw("Tool/AxisCode"), TestTags.Raw("Tool/AxisName"),
|
||||
};
|
||||
var options = new HeidenhainDriverOptions
|
||||
{
|
||||
Devices = [new HeidenhainDeviceOptions { Host = Host!, Port = Port, DeviceName = "dev1" }],
|
||||
RawTags = tags,
|
||||
Access = new HeidenhainAccessOptions { InspectPassword = Environment.GetEnvironmentVariable("OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD") is { Length: > 0 } ? "env:OTOPCUA_HEIDENHAIN_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[1].StatusCode != HeidenhainStatus.Good || values[3].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[1].StatusCode.ShouldBe(HeidenhainStatus.Good);
|
||||
values[1].Value.ShouldNotBe("Unknown");
|
||||
values[3].StatusCode.ShouldBe(HeidenhainStatus.Good);
|
||||
values[5].StatusCode.ShouldBe(HeidenhainStatus.Good);
|
||||
// A name leaf never disagrees with its raw leaf's quality.
|
||||
values[7].StatusCode.ShouldBe(values[6].StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>#867: integer tool leaves must reach clients with their declared runtime type, not Double.</summary>
|
||||
[Fact]
|
||||
public async Task Real_control_tool_leaves_keep_integer_types()
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
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>
|
||||
/// PLC-memory (<c>R_PR</c>/<c>R_MB</c>) and DATA-path (<c>R_DP</c>) parity with pyLSV2 (MIT). Where pyLSV2
|
||||
/// disagrees with a real iTNC 530 capture the capture wins and the test pins the capture (see
|
||||
/// <c>docs/drivers/Heidenhain.md</c> §"pyLSV2 correspondence").
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainPlcDataPyLsv2ParityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(HeidenhainPlcArea.Marker)]
|
||||
[InlineData(HeidenhainPlcArea.Input)]
|
||||
[InlineData(HeidenhainPlcArea.Output)]
|
||||
[InlineData(HeidenhainPlcArea.Counter)]
|
||||
[InlineData(HeidenhainPlcArea.Timer)]
|
||||
public void Plc_bit_areas_accept_the_ff_true_byte_real_controls_return_for_the_same_operands(HeidenhainPlcArea area)
|
||||
{
|
||||
// R_DP /PLC/memory/I/999 answered type 11 + FF on hermle59; pyLSV2 read_plc_memory unpacks "?" (non-zero = true).
|
||||
HeidenhainBindingCodec.DecodePlc(area, [0xFF], DriverDataType.Boolean).ShouldBe(true);
|
||||
HeidenhainBindingCodec.DecodePlc(area, [0x01], DriverDataType.Boolean).ShouldBe(true);
|
||||
HeidenhainBindingCodec.DecodePlc(area, [0x00], DriverDataType.Boolean).ShouldBe(false);
|
||||
// Still strict: pyLSV2 would call 0x02 true, but no control has been seen to send it.
|
||||
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(area, [0x02], DriverDataType.Boolean));
|
||||
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(area, [0x00, 0x00], DriverDataType.Boolean));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_captured_ff_bit_is_the_same_byte_the_r_mb_decoder_now_accepts()
|
||||
{
|
||||
var f = Fixtures.Load("plc-count-units.properties");
|
||||
var reply = f["hermle59.I.999"]; // S_DP:0000000bff
|
||||
var value = Convert.FromHexString(reply["S_DP:".Length..]);
|
||||
Lsv2Data.DataPath(value).ShouldBe(true);
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Input, value.AsSpan(4), DriverDataType.Boolean).ShouldBe(true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PLC:INPUT_DWORD:3", HeidenhainPlcArea.InputDword)]
|
||||
[InlineData("PLC:OUTPUT_DWORD:0", HeidenhainPlcArea.OutputDword)]
|
||||
public void Input_and_output_dword_areas_parse_as_element_indexes(string address, HeidenhainPlcArea area)
|
||||
{
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int32"), out var d, out var error)
|
||||
.ShouldBeTrue(error);
|
||||
d!.Address.Area.ShouldBe(area);
|
||||
d.Address.Text.ShouldBe(address);
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int64"), out _, out _).ShouldBeTrue();
|
||||
// int32 must not be narrowed into Int16; it is not a Boolean/String area.
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int16"), out _, out _).ShouldBeFalse();
|
||||
HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Boolean"), out _, out _).ShouldBeFalse();
|
||||
HeidenhainAddress.AreaTokens.ShouldContain(address.Split(':')[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dword_io_views_share_the_word_pools_with_element_index_times_four()
|
||||
{
|
||||
var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10));
|
||||
var iw = layout.Region(HeidenhainPlcArea.InputWord)!;
|
||||
var ow = layout.Region(HeidenhainPlcArea.OutputWord)!;
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 1, layout).ShouldBe(new HeidenhainMemoryRange(iw.Base + 4, 4));
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 1, layout).ShouldBe(new HeidenhainMemoryRange(ow.Base + 4, 4));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 2, layout));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 2, layout));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle59")]
|
||||
[InlineData("hermle70")]
|
||||
[InlineData("hermle29")]
|
||||
public void Captured_layouts_give_dword_io_views_a_quarter_of_the_byte_pool(string machine)
|
||||
{
|
||||
// The input/output word pools are 1000 BYTES on these controls (IW998 is the last valid R_DP word); a
|
||||
// DWORD view therefore holds 250 elements. pyLSV2 caps INPUT_DWORD at count/4 too.
|
||||
var f = Fixtures.Load("plc-count-units.properties");
|
||||
var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"]));
|
||||
var iw = layout.Region(HeidenhainPlcArea.InputWord)!;
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 249, layout).Address.ShouldBe(iw.Base + 996);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 250, layout));
|
||||
var ow = layout.Region(HeidenhainPlcArea.OutputWord)!;
|
||||
HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 249, layout).Address.ShouldBe(ow.Base + 996);
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 250, layout));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle59")]
|
||||
[InlineData("hermle70")]
|
||||
[InlineData("hermle29")]
|
||||
public void Captured_layouts_contradict_pyLSV2_word_count_units(string machine)
|
||||
{
|
||||
// pyLSV2 read_plc_memory: BYTE max = count×2, WORD max = count, INPUT_WORD max = count (count read as words).
|
||||
// The real controls refuse B10000 / W10000 / IW1000 over R_DP, and each advertised base + count is exactly
|
||||
// the next advertised base — the counts are bytes. Pin the capture, not pyLSV2.
|
||||
var f = Fixtures.Load("plc-count-units.properties");
|
||||
var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"]));
|
||||
var words = layout.Region(HeidenhainPlcArea.Word)!;
|
||||
var iw = layout.Region(HeidenhainPlcArea.InputWord)!;
|
||||
var ow = layout.Region(HeidenhainPlcArea.OutputWord)!;
|
||||
var markers = layout.Region(HeidenhainPlcArea.Marker)!;
|
||||
(words.Base + words.Bytes).ShouldBe(iw.Base);
|
||||
(iw.Base + iw.Bytes).ShouldBe(ow.Base);
|
||||
(ow.Base + ow.Bytes).ShouldBe(markers.Base);
|
||||
f[machine + ".B.10000"].ShouldStartWith("T_ER");
|
||||
f[machine + ".W.9998"].ShouldStartWith("S_DP");
|
||||
f[machine + ".IW.998"].ShouldStartWith("S_DP");
|
||||
f[machine + ".IW.1000"].ShouldStartWith("T_ER");
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 10000, layout));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 5000, layout));
|
||||
Should.Throw<HeidenhainRangeException>(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 500, layout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dword_io_decodes_signed_int32_little_endian()
|
||||
{
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputDword, [0xFE, 0xFF, 0xFF, 0xFF], DriverDataType.Int32).ShouldBe(-2);
|
||||
HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.OutputDword, [0x01, 0x02, 0x03, 0x04], DriverDataType.Int64).ShouldBe(0x04030201L);
|
||||
Should.Throw<Lsv2DecodeException>(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputDword, [0, 0], DriverDataType.Int32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plc_request_is_element_index_times_width_plus_base_like_pyLSV2()
|
||||
{
|
||||
// pyLSV2 read_plc_memory: address = start + first_element × mem_byte_count; count byte = width for one element.
|
||||
var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10));
|
||||
var w = layout.Region(HeidenhainPlcArea.Word)!;
|
||||
var range = HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 3, layout);
|
||||
range.ShouldBe(new HeidenhainMemoryRange(w.Base + 6, 2));
|
||||
var payload = Lsv2ReadOnlyPolicy.MemoryPayload(range.Address, range.Bytes);
|
||||
BinaryPrimitives.ReadUInt32BigEndian(payload).ShouldBe((uint)(w.Base + 6));
|
||||
payload[4].ShouldBe((byte)2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_path_request_matches_pyLSV2_formatting()
|
||||
{
|
||||
// pyLSV2 read_data_path: four 0x00 bytes + path with / → \ and " → ' + NUL.
|
||||
var payload = Lsv2ReadOnlyPolicy.DataPathPayload("/TABLE/TOOL/T/1/\"DOC\"");
|
||||
payload[..4].ShouldBe(new byte[4]);
|
||||
Encoding.Latin1.GetString(payload, 4, payload.Length - 4).ShouldBe("\\TABLE\\TOOL\\T\\1\\'DOC'\0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_path_value_types_match_pyLSV2_table()
|
||||
{
|
||||
Lsv2Data.DataPath([0, 0, 0, 2, 0xFF, 0xFE]).ShouldBe(-2); // !h
|
||||
Lsv2Data.DataPath([0, 0, 0, 3, 0xFF, 0xFF, 0xFF, 0xFE]).ShouldBe(-2); // !l
|
||||
var dbl = new byte[12];
|
||||
dbl[3] = 5;
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(dbl.AsSpan(4), 1.25); // <d
|
||||
Lsv2Data.DataPath(dbl).ShouldBe(1.25);
|
||||
Lsv2Data.DataPath([0, 0, 0, 8, (byte)'A', 0]).ShouldBe("A");
|
||||
Lsv2Data.DataPath([0, 0, 0, 16, 0xFF]).ShouldBe(-1); // !b
|
||||
Lsv2Data.DataPath([0, 0, 0, 17, 0xFF]).ShouldBe(255); // !B
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
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>
|
||||
/// Parity with the open-source pyLSV2 (MIT, commit <c>97a3d691</c>) for every <c>R_RI</c> selector the driver
|
||||
/// reads. The golden expectations below are what pyLSV2's own decoders (<c>misc.py:decode_axis_location</c>,
|
||||
/// <c>decode_stack_info</c>, <c>decode_error_message</c>, <c>decode_tool_info</c>, <c>const.py:ExecState</c> /
|
||||
/// <c>PgmState</c> / <c>LSV2StatusCode</c>) return for the captured iTNC 530 replies — produced by running pyLSV2
|
||||
/// over <c>Fixtures/itnc530-inspect.properties</c>, then pinned here.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeidenhainPyLsv2ParityTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static byte[] Payload(string machine, int selector) =>
|
||||
SimReply.FromFixture(Fixtures.Load("itnc530-inspect.properties")[$"{machine}.{selector}"]).Payload!;
|
||||
|
||||
// ---- decoders vs pyLSV2 on the real captures -------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle49", new[] { 0.08392, 10.4304, 25.72673, 0.0, 270.0, -90.0 }, "TNC:\\PARTPROG\\00588001302.i")]
|
||||
[InlineData("hermle59", new[] { 0.01119, 10.51793, 25.31073, 0.0, 180.0, -90.0 }, "TNC:\\partprog\\00588001302.i")]
|
||||
public void Captured_replies_decode_exactly_as_pyLsv2_decodes_them(string machine, double[] positions, string main)
|
||||
{
|
||||
var axes = Lsv2Data.Axes(Payload(machine, 22));
|
||||
axes.Names.ShouldBe(["X", "Y", "Z", "C", "a", "A"]);
|
||||
axes.Positions.ShouldBe(positions);
|
||||
|
||||
HeidenhainStateNames.ExecutionState(Lsv2Data.U16(Payload(machine, 23))).ShouldBe("Automatic"); // pyLSV2 AUTOMATIC
|
||||
HeidenhainStateNames.ProgramState(Lsv2Data.U16(Payload(machine, 26))).ShouldBe("Idle"); // pyLSV2 IDLE
|
||||
|
||||
var program = Lsv2Data.Program(Payload(machine, 24));
|
||||
program.Line.ShouldBe(0L);
|
||||
program.Main.ShouldBe(main);
|
||||
program.Current.ShouldBe("");
|
||||
|
||||
var alarm = Lsv2Data.Alarm(Payload(machine, 27));
|
||||
(alarm.ErrorClass, alarm.Group, alarm.Number, alarm.Message).ShouldBe((7, 4, -1, "Run test of cutout channels"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Captured_24_byte_tool_reply_decodes_as_pyLsv2_with_axis_Z()
|
||||
{
|
||||
var tool = Lsv2Data.Tool(Payload("hermle59", 51));
|
||||
(tool.Number, tool.Index, tool.Axis, tool.Length, tool.Radius).ShouldBe((0L, 0, 2, 0.0, 0.0));
|
||||
HeidenhainStateNames.ToolAxis(tool.Axis).ShouldBe("Z");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hermle49", 25, 23, "T_ER_NO_PRIV")]
|
||||
[InlineData("hermle49", 28, 57, "T_ER_NO_NEXT_ERROR")]
|
||||
[InlineData("hermle49", 51, 24, "T_ER_WRONG_PARA")]
|
||||
[InlineData("hermle59", 25, 23, "T_ER_NO_PRIV")]
|
||||
public void Captured_refusals_carry_the_pyLsv2_status_code_names(string machine, int selector, int code, string name)
|
||||
{
|
||||
var refusal = Payload(machine, selector);
|
||||
refusal[1].ShouldBe((byte)code);
|
||||
Lsv2RefusalCodes.Name(code).ShouldBe(name);
|
||||
new Lsv2RefusalException(refusal[0], refusal[1]).Message.ShouldContain(name);
|
||||
}
|
||||
|
||||
// ---- decoders vs pyLSV2 layouts on synthetic vectors -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Overrides_are_feed_spindle_rapid_hundredths_like_pyLsv2()
|
||||
{
|
||||
var o = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(o, 10000);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(o.AsSpan(4), 15050);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(o.AsSpan(8), 1);
|
||||
Lsv2Data.Overrides(o).ShouldBe([100.0, 150.5, 0.01]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tool_geometry_is_little_endian_after_big_endian_identity_like_pyLsv2()
|
||||
{
|
||||
var t = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(t, 49);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(t.AsSpan(4), 3);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(t.AsSpan(6), 1);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), 138.1506);
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(16), -0.0101);
|
||||
var tool = Lsv2Data.Tool(t);
|
||||
(tool.Number, tool.Index, tool.Axis, tool.Length, tool.Radius).ShouldBe((49L, 3, 1, 138.1506, -0.0101));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alarm_number_is_signed_int32_like_pyLsv2()
|
||||
{
|
||||
// The live 2026-11-11 record 0x8100017B (HMI 702126) is negative as int32 — pyLSV2 unpacks "!l" too.
|
||||
var a = new byte[8 + 2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(a, 7);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(a.AsSpan(2), 3);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(a.AsSpan(4), 0x8100017Bu);
|
||||
a[8] = (byte)'x';
|
||||
Lsv2Data.Alarm(a).Number.ShouldBe(-2130706053);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timers_are_u32be_seconds_published_raw()
|
||||
{
|
||||
// per pyLSV2 const.py:ParRRI: u32be, NC uptime +1/s. 0x0001_5180 = 86 400 s = one day; no rescaling.
|
||||
Lsv2Data.U32([0x00, 0x01, 0x51, 0x80]).ShouldBe(86400L);
|
||||
Lsv2Data.U32([0xFF, 0xFF, 0xFF, 0xFF]).ShouldBe(4294967295L);
|
||||
Should.Throw<Lsv2DecodeException>(() => Lsv2Data.U32([0, 0, 1]));
|
||||
}
|
||||
|
||||
// ---- enum names ------------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Name_tables_cover_every_pyLsv2_enum_member_in_order()
|
||||
{
|
||||
// per pyLSV2 const.py:ExecState 0..5 and PgmState 0..8 (UPPER_SNAKE there, PascalCase here).
|
||||
HeidenhainStateNames.ExecutionStates.ShouldBe(["Manual", "Mdi", "PassReferences", "SingleStep", "Automatic", "Undefined"]);
|
||||
HeidenhainStateNames.ProgramStates.ShouldBe(
|
||||
["Started", "Stopped", "Finished", "Cancelled", "Interrupted", "Error", "ErrorCleared", "Idle", "Undefined"]);
|
||||
HeidenhainStateNames.ToolAxes.ShouldBe(["X", "Y", "Z"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(6)]
|
||||
[InlineData(300)]
|
||||
[InlineData(65535)]
|
||||
[InlineData(-1)]
|
||||
public void An_out_of_table_code_is_Unknown_never_an_invented_name(int code)
|
||||
{
|
||||
HeidenhainStateNames.ExecutionState(code).ShouldBe(HeidenhainStateNames.Unknown);
|
||||
HeidenhainStateNames.ToolAxis(code).ShouldBe(HeidenhainStateNames.Unknown);
|
||||
if (code != 6) HeidenhainStateNames.ProgramState(code).ShouldBe(HeidenhainStateNames.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_leaves_are_additive_String_catalog_entries_on_the_raw_leaves_feature()
|
||||
{
|
||||
foreach (var (name, raw) in new[] { ("Status/ExecutionStateName", "Status/ExecutionState"), ("Status/ProgramStateName", "Status/ProgramState"), ("Tool/AxisName", "Tool/AxisCode") })
|
||||
{
|
||||
HeidenhainCatalog.TryGet(name, out var n).ShouldBeTrue(name);
|
||||
HeidenhainCatalog.TryGet(raw, out var r).ShouldBeTrue(raw);
|
||||
n!.DataType.ShouldBe(Core.Abstractions.DriverDataType.String);
|
||||
n.IsArray.ShouldBeFalse();
|
||||
n.Feature.ShouldBe(r!.Feature); // one wire read serves both
|
||||
n.Group.ShouldBe(r.Group);
|
||||
r.DataType.ShouldBe(Core.Abstractions.DriverDataType.Int32); // the raw leaf is unchanged
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Name_leaves_share_the_raw_read_and_leave_the_raw_value_unchanged()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]);
|
||||
c.Runtime[26] = () => SimReply.Ok("S_RI", [0, 0]);
|
||||
c.Runtime[51] = () => SimReply.Ok("S_RI", [0, 0, 0, 17, 0, 0, 0, 2]); // 8-byte identity-only reply
|
||||
string[] addresses = ["Status/ExecutionState", "Status/ExecutionStateName", "Status/ProgramState", "Status/ProgramStateName",
|
||||
"Tool/AxisCode", "Tool/AxisName", "Tool/Length"];
|
||||
await using var sim = Lsv2Simulator.WithControl(c);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
|
||||
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
|
||||
var v = await session.ReadBatchAsync(addresses.Select(a => TestTags.Def(a)).ToList(), Ct);
|
||||
|
||||
v[0].Value.ShouldBeOfType<int>().ShouldBe(4);
|
||||
v[1].Value.ShouldBe("Automatic");
|
||||
v[2].Value.ShouldBeOfType<int>().ShouldBe(0);
|
||||
v[3].Value.ShouldBe("Started");
|
||||
v[4].Value.ShouldBeOfType<int>().ShouldBe(2);
|
||||
v[5].Value.ShouldBe("Z");
|
||||
v[5].Status.ShouldBe(HeidenhainStatus.Good); // the axis is in the identity part, served even without geometry
|
||||
v[6].Status.ShouldBe(HeidenhainStatus.BadNotSupported);
|
||||
sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 23).ShouldBe(1);
|
||||
sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 26).ShouldBe(1);
|
||||
sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 51).ShouldBe(1);
|
||||
c.Violations.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_raw_selector_makes_its_name_leaf_bad_with_the_same_status()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[26] = () => SimReply.Refuse(23);
|
||||
await using var sim = Lsv2Simulator.WithControl(c);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
|
||||
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
|
||||
var v = await session.ReadBatchAsync([TestTags.Def("Status/ProgramState"), TestTags.Def("Status/ProgramStateName")], Ct);
|
||||
|
||||
v.ShouldAllBe(s => s.Status == HeidenhainStatus.BadUserAccessDenied && s.Value == null);
|
||||
}
|
||||
|
||||
// ---- refusal codes ---------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(22, HeidenhainStatus.BadNotSupported, true)] // T_ER_UNKNOWN_TELE
|
||||
[InlineData(24, HeidenhainStatus.BadNotSupported, true)] // T_ER_WRONG_PARA
|
||||
[InlineData(23, HeidenhainStatus.BadUserAccessDenied, true)] // T_ER_NO_PRIV
|
||||
[InlineData(49, HeidenhainStatus.BadUserAccessDenied, true)] // T_ER_NO_LOGIN
|
||||
[InlineData(52, HeidenhainStatus.BadOutOfRange, false)] // T_ER_BAD_MEMADR
|
||||
[InlineData(72, HeidenhainStatus.BadOutOfRange, false)] // T_ER_OUT_OF_RANGE
|
||||
[InlineData(58, HeidenhainStatus.BadTimeout, false)] // T_ER_ACCESS_TIMEOUT
|
||||
[InlineData(56, HeidenhainStatus.BadInvalidState, false)] // T_ER_OPERATING_MODE
|
||||
[InlineData(57, HeidenhainStatus.BadNoData, false)] // T_ER_NO_NEXT_ERROR (outside the alarm walk)
|
||||
[InlineData(25, HeidenhainStatus.BadUnexpectedError, false)] // T_ER_BREAK
|
||||
[InlineData(20, HeidenhainStatus.BadUnexpectedError, false)] // T_ER_BAD_FORMAT
|
||||
[InlineData(250, HeidenhainStatus.BadUnexpectedError, false)] // not in the table
|
||||
public void Refusal_codes_map_to_the_most_specific_status_and_only_the_old_stable_set_is_stable(int code, uint status, bool stable)
|
||||
{
|
||||
HeidenhainStatus.FromRefusal(code).ShouldBe(status);
|
||||
HeidenhainStatus.IsStable(HeidenhainStatus.FromRefusal(code)).ShouldBe(stable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_status_constants_are_the_canonical_opc_ua_numerics()
|
||||
{
|
||||
HeidenhainStatus.BadTimeout.ShouldBe(0x800A0000u);
|
||||
HeidenhainStatus.BadInvalidState.ShouldBe(0x80AF0000u);
|
||||
HeidenhainStatus.BadNoData.ShouldBe(0x809B0000u);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(23, "T_ER_NO_PRIV")]
|
||||
[InlineData(24, "T_ER_WRONG_PARA")]
|
||||
[InlineData(52, "T_ER_BAD_MEMADR")]
|
||||
[InlineData(57, "T_ER_NO_NEXT_ERROR")]
|
||||
[InlineData(58, "T_ER_ACCESS_TIMEOUT")]
|
||||
[InlineData(111, "T_BD_CANCELLED")]
|
||||
[InlineData(1, "UNKNOWN_1")]
|
||||
public void Refusal_names_follow_the_pyLsv2_status_code_table(int code, string name) => Lsv2RefusalCodes.Name(code).ShouldBe(name);
|
||||
|
||||
[Fact]
|
||||
public async Task An_access_timeout_refusal_is_BadTimeout_and_retried_not_suppressed()
|
||||
{
|
||||
var calls = 0;
|
||||
var c = new SimulatedControl();
|
||||
c.Runtime[23] = () => Interlocked.Increment(ref calls) == 1 ? SimReply.Refuse(58) : SimReply.Ok("S_RI", [0, 4]);
|
||||
await using var sim = Lsv2Simulator.WithControl(c);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
|
||||
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
var tag = TestTags.Def("Status/ExecutionState");
|
||||
|
||||
(await session.ReadBatchAsync([tag], Ct))[0].Status.ShouldBe(HeidenhainStatus.BadTimeout);
|
||||
var second = (await session.ReadBatchAsync([tag], Ct))[0];
|
||||
|
||||
second.Status.ShouldBe(HeidenhainStatus.Good);
|
||||
second.Value.ShouldBe(4);
|
||||
calls.ShouldBe(2);
|
||||
session.CommunicationLost.ShouldBeFalse(); // a refusal is never a transport failure
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_57_ends_the_alarm_walk_other_refusals_fail_it()
|
||||
{
|
||||
var c = new SimulatedControl();
|
||||
var record = new byte[10];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(record, 1);
|
||||
record[8] = (byte)'a';
|
||||
c.Runtime[27] = () => SimReply.Ok("S_RI", record);
|
||||
c.Runtime[28] = () => SimReply.Refuse(58);
|
||||
await using var sim = Lsv2Simulator.WithControl(c);
|
||||
var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
|
||||
var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false);
|
||||
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
|
||||
await client.OpenAsync(Ct);
|
||||
await session.InitializeAsync(Ct);
|
||||
|
||||
var count = (await session.ReadBatchAsync([TestTags.Def("Alarms/Count")], Ct))[0];
|
||||
|
||||
count.Status.ShouldBe(HeidenhainStatus.BadTimeout); // never a truncated list of 1
|
||||
c.Runtime[28] = () => SimReply.Refuse(57);
|
||||
(await session.ReadBatchAsync([TestTags.Def("Alarms/Count")], Ct))[0].Value.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_read_only_allowlist_is_unchanged()
|
||||
{
|
||||
// This work adds derived leaves only: no new selector (pyLSV2's 20/21 axis names stay out), request type or role.
|
||||
Lsv2ReadOnlyPolicy.Selectors.OrderBy(s => s).ShouldBe([22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51]);
|
||||
Lsv2ReadOnlyPolicy.Roles.OrderBy(r => r, StringComparer.Ordinal).ShouldBe(["DATA", "DNC", "INSPECT"]);
|
||||
Lsv2ReadOnlyPolicy.Commands.OrderBy(c => c, StringComparer.Ordinal).ShouldBe(["A_LG", "R_DP", "R_MB", "R_PR", "R_RI", "R_VR"]);
|
||||
foreach (var s in new[] { 20, 21 })
|
||||
{
|
||||
var payload = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(payload, (ushort)s);
|
||||
Should.Throw<Exception>(() => Lsv2ReadOnlyPolicy.Check("R_RI", payload), $"selector {s}");
|
||||
}
|
||||
|
||||
Should.Throw<Exception>(() => Lsv2ReadOnlyPolicy.Check("A_LG", Encoding.Latin1.GetBytes("FILE\0\0")));
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ public sealed class Lsv2DataTests
|
||||
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.Marker, [0x02], 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));
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
|
||||
|
||||
/// <summary>How a simulated family answers the <c>R_RI</c> selectors the driver reads.</summary>
|
||||
public enum SimSelectorBehaviour
|
||||
{
|
||||
/// <summary>Every read selector answers (the iTNC 530 shape, hardware-captured).</summary>
|
||||
All,
|
||||
|
||||
/// <summary>
|
||||
/// The TNC 640 observation (pyLSV2 docs/protocol.rst "Differences between different control versions",
|
||||
/// NC 340590 10 SP2): 27/28/51 and 30–34 refuse <c>T_ER_UNKNOWN_TELE</c> (22); 25/26 need DNC; 24 serves the
|
||||
/// program without DNC but with block number 0.
|
||||
/// </summary>
|
||||
TncObserved,
|
||||
|
||||
/// <summary>
|
||||
/// Core telemetry only (22–26). pyLSV2's own suite asserts overrides/axes/exec state on every control but gates
|
||||
/// tool (51) and error messages (27/28) to iTNC (tests/test_read_info.py), and its protocol table marks them
|
||||
/// "no" on CNCpilot — so 27/28/51 and the iTNC lifetime timers 30–34 are refused 22 here. A conservative
|
||||
/// assumption, not an observation, for the families pyLSV2 records nothing about.
|
||||
/// </summary>
|
||||
CoreOnly,
|
||||
}
|
||||
|
||||
/// <summary>One simulated HEIDENHAIN control family.</summary>
|
||||
/// <param name="Name">Test display name.</param>
|
||||
/// <param name="Model">The R_VR model field.</param>
|
||||
/// <param name="Nc">The R_VR NC software field.</param>
|
||||
/// <param name="Family">The family the driver must classify it as.</param>
|
||||
/// <param name="Selectors">Which R_RI selectors answer.</param>
|
||||
/// <param name="FiveFieldVersion">True to send the 5-field R_VR (model, NC, PLC, SPLC, options).</param>
|
||||
/// <param name="Source">Where the profile's strings come from.</param>
|
||||
public sealed record ControlProfile(
|
||||
string Name, string Model, string Nc, HeidenhainControlFamily Family, SimSelectorBehaviour Selectors,
|
||||
bool FiveFieldVersion, string Source)
|
||||
{
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-family simulator profiles. Model/NC strings come from the pyLSV2 (MIT) README "Programming Stations" and
|
||||
/// "Machines" tables and dat_cls.py:VersionInfo; the iTNC 530 one from our own hardware record
|
||||
/// (docs/drivers/evidence/heidenhain-live-2026-11-11.md). The R_VR field count follows pyLSV2 (MIT)
|
||||
/// client.py:_read_version, which accepts exactly 4 or 5 NUL-separated fields on every family — which family
|
||||
/// sends which shape is not recorded, so the 5-field variant is exercised on more than one family.
|
||||
/// </summary>
|
||||
public static class ControlProfiles
|
||||
{
|
||||
public static readonly ControlProfile ITnc530 = new("iTNC 530", "iTNC530", "340490 04 SP10",
|
||||
HeidenhainControlFamily.ITnc530, SimSelectorBehaviour.All, false, "hardware: Hermle59 2026-11-11");
|
||||
|
||||
public static readonly ControlProfile ITnc530Spaced = new("iTNC 530 (spaced)", "iTNC 530", "606420 03 SP4",
|
||||
HeidenhainControlFamily.ITnc530, SimSelectorBehaviour.All, true, "pyLSV2 dat_cls.py strips spaces before classifying");
|
||||
|
||||
public static readonly ControlProfile Tnc640 = new("TNC 640", "TNC640", "340590 10 SP2",
|
||||
HeidenhainControlFamily.Tnc, SimSelectorBehaviour.TncObserved, false, "pyLSV2 docs/protocol.rst + README");
|
||||
|
||||
public static readonly ControlProfile Tnc620 = new("TNC 620", "TNC620", "817605 04 SP1",
|
||||
HeidenhainControlFamily.Tnc, SimSelectorBehaviour.TncObserved, true, "pyLSV2 README Machines");
|
||||
|
||||
public static readonly ControlProfile Tnc320 = new("TNC 320", "TNC320", "340554 04 SP1",
|
||||
HeidenhainControlFamily.Tnc, SimSelectorBehaviour.TncObserved, false, "pyLSV2 README Programming Stations");
|
||||
|
||||
public static readonly ControlProfile Tnc128 = new("TNC 128", "TNC 128", "771841 01",
|
||||
HeidenhainControlFamily.Tnc, SimSelectorBehaviour.TncObserved, false, "pyLSV2 dat_cls.py prefix only (NC string illustrative)");
|
||||
|
||||
public static readonly ControlProfile Tnc7 = new("TNC7", "TNC7", "817625 17",
|
||||
HeidenhainControlFamily.Tnc7, SimSelectorBehaviour.CoreOnly, true, "pyLSV2 README Programming Stations");
|
||||
|
||||
public static readonly ControlProfile CncPilot640 = new("CNC PILOT 640", "CNCpilot640", "1230521 03 SP1",
|
||||
HeidenhainControlFamily.Lathe, SimSelectorBehaviour.CoreOnly, false, "pyLSV2 README Programming Stations");
|
||||
|
||||
public static readonly ControlProfile ManualPlus620 = new("MANUALplus 620", "MANUALplus620", "634130 02 SP7",
|
||||
HeidenhainControlFamily.Lathe, SimSelectorBehaviour.CoreOnly, false, "pyLSV2 README Programming Stations");
|
||||
|
||||
public static readonly ControlProfile MillPlus = new("MillPlus IT V600", "MillPlusIT V600", "538956 03 SP15",
|
||||
HeidenhainControlFamily.MillPlus, SimSelectorBehaviour.CoreOnly, false, "pyLSV2 README Programming Stations");
|
||||
|
||||
public static readonly ControlProfile Tnc430 = new("TNC 430 (legacy)", "TNC 430", "280476 21",
|
||||
HeidenhainControlFamily.LegacyTnc, SimSelectorBehaviour.CoreOnly, false, "no pyLSV2 rule (it reports UNKNOWN); illustrative");
|
||||
|
||||
public static readonly ControlProfile Unrecognised = new("Unrecognised", "Anilam 6000i", "000000 00",
|
||||
HeidenhainControlFamily.Unknown, SimSelectorBehaviour.CoreOnly, false, "pyLSV2 ControlType.UNKNOWN");
|
||||
|
||||
public static IReadOnlyList<ControlProfile> All { get; } =
|
||||
[ITnc530, ITnc530Spaced, Tnc640, Tnc620, Tnc320, Tnc128, Tnc7, CncPilot640, ManualPlus620, MillPlus, Tnc430, Unrecognised];
|
||||
|
||||
/// <summary>The R_VR reply for a profile.</summary>
|
||||
public static byte[] VersionReply(ControlProfile p) => Encoding.Latin1.GetBytes(p.FiveFieldVersion
|
||||
? $"{p.Model}\0{p.Nc}\0PLC1\0SPLC1\0%0000111100000111\0"
|
||||
: $"{p.Model}\0{p.Nc}\0PLC1\0%0000111100000111\0");
|
||||
|
||||
/// <summary>A simulated control speaking the profile. <c>DataPaths["/x"]</c> is populated for every family, so a gate — not the simulator — is what keeps non-iTNC DATA off the wire.</summary>
|
||||
public static SimulatedControl Build(ControlProfile p)
|
||||
{
|
||||
var dnc = 0;
|
||||
var c = new SimulatedControl
|
||||
{
|
||||
Version = VersionReply(p),
|
||||
Login = (role, _) =>
|
||||
{
|
||||
if (role == "DNC") Interlocked.Exchange(ref dnc, 1);
|
||||
return SimReply.Ok("T_OK", []);
|
||||
},
|
||||
};
|
||||
bool Dnc() => Volatile.Read(ref dnc) == 1;
|
||||
|
||||
c.Runtime[22] = () => SimReply.Ok("S_RI", [0, 2, .. Encoding.ASCII.GetBytes("1.5\0-2.25\0X\0Z\0")]);
|
||||
c.Runtime[23] = () => SimulatedControl.U16(4);
|
||||
c.DataPaths["/x"] = SimReply.Ok("S_DP", [0, 0, 0, 3, 0, 0, 0, 42]);
|
||||
switch (p.Selectors)
|
||||
{
|
||||
case SimSelectorBehaviour.TncObserved:
|
||||
c.Runtime[24] = () => SimReply.Ok("S_RI", Program(Dnc() ? 5 : 0));
|
||||
c.Runtime[25] = () => Dnc() ? SimReply.Ok("S_RI", Overrides()) : SimReply.Refuse(23);
|
||||
c.Runtime[26] = () => Dnc() ? SimulatedControl.U16(7) : SimReply.Refuse(23);
|
||||
foreach (var s in new[] { 27, 28, 51, 30, 31, 32, 33, 34 }) c.Runtime[s] = () => SimReply.Refuse(22);
|
||||
break;
|
||||
case SimSelectorBehaviour.CoreOnly:
|
||||
c.Runtime[24] = () => SimReply.Ok("S_RI", Program(5));
|
||||
c.Runtime[25] = () => SimReply.Ok("S_RI", Overrides());
|
||||
c.Runtime[26] = () => SimulatedControl.U16(7);
|
||||
foreach (var s in new[] { 27, 28, 51, 30, 31, 32, 33, 34 }) c.Runtime[s] = () => SimReply.Refuse(22);
|
||||
break;
|
||||
default:
|
||||
c.Runtime[24] = () => SimReply.Ok("S_RI", Program(5));
|
||||
c.Runtime[25] = () => SimReply.Ok("S_RI", Overrides());
|
||||
c.Runtime[26] = () => SimulatedControl.U16(7);
|
||||
c.Runtime[27] = () => SimReply.Ok("S_RI", Alarm());
|
||||
c.Runtime[28] = () => SimReply.Refuse(57);
|
||||
c.Runtime[51] = () => SimReply.Ok("S_RI", new byte[24]);
|
||||
for (var s = 30; s <= 34; s++) c.Runtime[s] = () => SimReply.Ok("S_RI", [0, 0, 1, 0]);
|
||||
break;
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
private static byte[] Program(int line)
|
||||
{
|
||||
var b = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(b, (uint)line);
|
||||
return [.. b, .. "TNC:\\nc_prog\\a.h\0TNC:\\nc_prog\\a.h\0"u8.ToArray()];
|
||||
}
|
||||
|
||||
private static byte[] Overrides()
|
||||
{
|
||||
var b = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(b, 10000);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(4), 9000);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(b.AsSpan(8), 5000);
|
||||
return b;
|
||||
}
|
||||
|
||||
private static byte[] Alarm() => [0, 7, 0, 4, 0xff, 0xff, 0xff, 0xff, .. "Run test of cutout channels\0"u8.ToArray()];
|
||||
}
|
||||
+1
-1
@@ -23,7 +23,7 @@ public sealed record SimReply(SimReplyKind Kind, string? Command = null, byte[]?
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A small, mutable model of an iTNC 530 speaking the six allowlisted LSV2 requests. Anything else is
|
||||
/// A small, mutable model of an iTNC 530 (other families: <see cref="ControlProfiles"/>) 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
|
||||
|
||||
Reference in New Issue
Block a user