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
@@ -102,6 +102,8 @@ public static class HeidenhainCatalog
}
Add("Controller", DriverDataType.String, false, HeidenhainPollGroup.Slow, IdentityFeature, "Model", "NcVersion", "PlcVersion", "Options");
// Derived locally from the same R_VR reply (HeidenhainControlFamilies.Classify) — no extra wire request.
Add("Controller", DriverDataType.String, false, HeidenhainPollGroup.Slow, IdentityFeature, "Family");
Add("Status", DriverDataType.Int32, false, HeidenhainPollGroup.Status, "Execution", "ExecutionState");
Add("Status", DriverDataType.Int32, false, HeidenhainPollGroup.Status, "ProgramState", "ProgramState");
Add("Program", DriverDataType.String, false, HeidenhainPollGroup.Status, "Program", "Main", "Current");
@@ -0,0 +1,77 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
/// <summary>
/// The HEIDENHAIN control generation, classified from the <c>R_VR</c> model field. The published value of the
/// <c>Controller/Family</c> leaf is the member name. Only <see cref="ITnc530"/> is hardware-proven; every other
/// member follows pyLSV2 (MIT) and is hardware-unproven here (<c>docs/drivers/Heidenhain.md</c> §Control families).
/// </summary>
public enum HeidenhainControlFamily
{
/// <summary>A model string no rule recognises. Treated like a non-iTNC: no iTNC-only feature is attempted.</summary>
Unknown,
/// <summary>iTNC 530 (pyLSV2 <c>ControlType.MILL_OLD</c>) — the only family proven on hardware.</summary>
ITnc530,
/// <summary>TNC 640 / 620 / 320 / 128 (pyLSV2 <c>ControlType.MILL_NEW</c>).</summary>
Tnc,
/// <summary>TNC7 (pyLSV2 <c>ControlType.TNC7</c>: "behaves just a bit different again").</summary>
Tnc7,
/// <summary>CNC PILOT 640 / MANUALplus 620 lathe controls (pyLSV2 <c>ControlType.LATHE_NEW</c>).</summary>
Lathe,
/// <summary>MillPlus (IT V600) — pyLSV2 <c>ControlType.MILLPLUS</c>, "not officially supported by any other Heidenhain tool".</summary>
MillPlus,
/// <summary>
/// Legacy TNC 426 / 430. NOT a pyLSV2 class (pyLSV2 reports these as <c>UNKNOWN</c>); named here only so the
/// leaf is informative. It carries no gate of its own beyond "not an iTNC".
/// </summary>
LegacyTnc,
}
/// <summary>The single authority that classifies a control and states which reference-gated features it has.</summary>
public static class HeidenhainControlFamilies
{
// per pyLSV2 (MIT) dat_cls.py:VersionInfo.control setter — strip every space, upper-case, first matching
// prefix wins. ITNC530 is tested before any TNC prefix; TNC7 cannot collide with TNC640/620/320/128.
private static readonly (string Prefix, HeidenhainControlFamily Family)[] Rules =
[
("ITNC530", HeidenhainControlFamily.ITnc530),
("TNC640", HeidenhainControlFamily.Tnc),
("TNC620", HeidenhainControlFamily.Tnc),
("TNC320", HeidenhainControlFamily.Tnc),
("TNC128", HeidenhainControlFamily.Tnc),
("MANUALPLUS", HeidenhainControlFamily.Lathe),
("CNCPILOT640", HeidenhainControlFamily.Lathe),
("TNC7", HeidenhainControlFamily.Tnc7),
("MILLPLUS", HeidenhainControlFamily.MillPlus),
// Not from pyLSV2 (it has no rule for these): legacy pre-iTNC milling controls.
("TNC426", HeidenhainControlFamily.LegacyTnc),
("TNC430", HeidenhainControlFamily.LegacyTnc),
];
/// <summary>Classifies the <c>R_VR</c> model field (e.g. <c>iTNC530</c>, <c>TNC 640</c>, <c>CNCpilot640</c>).</summary>
/// <param name="model">The model string as the control reported it; null or blank is <see cref="HeidenhainControlFamily.Unknown"/>.</param>
/// <returns>The family.</returns>
public static HeidenhainControlFamily Classify(string? model)
{
if (string.IsNullOrWhiteSpace(model)) return HeidenhainControlFamily.Unknown;
var normalized = model.Replace(" ", "", StringComparison.Ordinal).Trim().ToUpperInvariant();
foreach (var (prefix, family) in Rules)
if (normalized.StartsWith(prefix, StringComparison.Ordinal)) return family;
return HeidenhainControlFamily.Unknown;
}
/// <summary>
/// True when <c>R_DP</c> data-path reads (and therefore the DATA login) may be attempted. Per pyLSV2 (MIT)
/// client.py:read_data_path — "does not work on non iTNC controls" — this is the iTNC 530 alone; an
/// <see cref="HeidenhainControlFamily.Unknown"/> control is refused too, so no unrecognised control ever
/// receives a DATA login.
/// </summary>
/// <param name="family">The family.</param>
/// <returns>Whether DATA is supported.</returns>
public static bool SupportsDataPath(HeidenhainControlFamily family) => family == HeidenhainControlFamily.ITnc530;
}
@@ -151,6 +151,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
/// <summary>The identity of the current session's control, when connected.</summary>
public string? ControllerModel { get { lock (_publication) return _session?.Identity?.Model; } }
/// <summary>The control family classified from the current session's identity, or null.</summary>
public HeidenhainControlFamily? ControllerFamily { get { lock (_publication) return _session?.Identity?.Family; } }
/// <summary>Starts the acquisition loop (idempotent).</summary>
public void Start()
{
@@ -417,6 +417,7 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi
bag[$"{d.HostName}.state"] = d.State.ToString();
bag[$"{d.HostName}.data"] = d.DataHealth;
if (d.ControllerModel is { } m) bag[$"{d.HostName}.model"] = m;
if (d.ControllerFamily is { } f) bag[$"{d.HostName}.family"] = f.ToString();
}
// lmxopcua#839: controller alarms no ALARM: selector claims (shown by no condition) — current + since start.
@@ -73,7 +73,7 @@ public sealed class HeidenhainDriverProbe : IDriverProbe
await client.LoginAsync("INSPECT", password, cts.Token).ConfigureAwait(false);
var versions = Lsv2Data.Versions(await client.ReadVersionAsync(cts.Token).ConfigureAwait(false));
sw.Stop();
return new(true, $"LSV2 INSPECT session OK at {target}: {versions.Model} NC {versions.Nc}", sw.Elapsed);
return new(true, $"LSV2 INSPECT session OK at {target}: {versions.Model} ({versions.Family}) NC {versions.Nc}", sw.Elapsed);
}
catch (OperationCanceledException)
{
@@ -78,8 +78,9 @@ internal sealed class HeidenhainSession : IAsyncDisposable
if (_settings.TelemetryEnabled && _settings.DncLoginEnabled) _dncStatus = await OptionalLoginAsync("DNC", ct).ConfigureAwait(false);
if (_settings.DataEnabled && _settings.HasDataBindings)
{
// Reference-derived DATA support is iTNC only.
_dataStatus = _identity.Model.Contains("itnc", StringComparison.OrdinalIgnoreCase)
// DATA (R_DP) is iTNC 530 only — per pyLSV2 (MIT) client.py:read_data_path. Any other family, and an
// unrecognised one, is Unsupported WITHOUT sending the DATA login.
_dataStatus = HeidenhainControlFamilies.SupportsDataPath(_identity.Family)
? await OptionalLoginAsync("DATA", ct).ConfigureAwait(false)
: HeidenhainStatus.BadNotSupported;
}
@@ -439,7 +440,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable
switch (value)
{
case Lsv2Versions v:
return name switch { "Model" => v.Model, "NcVersion" => v.Nc, "PlcVersion" => v.Plc, _ => v.Options };
return name switch { "Model" => v.Model, "NcVersion" => v.Nc, "PlcVersion" => v.Plc, "Family" => v.Family.ToString(), _ => v.Options };
case Lsv2Program p:
return name switch { "Main" => p.Main, "Current" => p.Current, _ => (object)p.Line };
case double[] o when address.StartsWith("Overrides/", StringComparison.Ordinal):
@@ -5,7 +5,11 @@ using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
/// <summary>Controller identity from <c>R_VR</c>.</summary>
public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options);
public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options)
{
/// <summary>The control family derived from <see cref="Model"/> (computed, no backing field: equality and the #831 identity-change signal are unchanged).</summary>
public HeidenhainControlFamily Family => HeidenhainControlFamilies.Classify(Model);
}
/// <summary>Program stack from <c>R_RI 24</c>.</summary>
public sealed record Lsv2Program(long Line, string Main, string Current);