Merge branch 'worktree-agent-a6ace38a85443d6d6' into heidenhain-pylsv2-parity

This commit is contained in:
Joseph Doherty
2026-11-12 23:56:08 -05:00
12 changed files with 573 additions and 9 deletions
@@ -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();
}
}
}
@@ -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()];
}
@@ -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