diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 88aba9960..a0afe400b 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -353,6 +353,9 @@ panel with the NAT/IPv6-zone/loose-match caveats) / CLI `--validate-host-name`. `BadConfigurationError` on that role's reads only (`HeidenhainCredentialPersisted`). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; `Category=LiveIntegration` (`HEIDENHAIN_LSV2_HOST`) is the commissioning gate. `docs/drivers/Heidenhain.md`. + Control family (`HeidenhainControlFamilies.Classify`, pyLSV2's prefix rule) → `Controller/Family` leaf; DATA is + iTNC 530-only (non-iTNC/Unknown ⇒ `Unsupported`, no DATA login sent); every other feature is reply-driven, never + pre-gated per family. Only iTNC 530 is hardware-proven (§Control families). - **Heidenhain alarms (#831):** an `ALARM:` selector (`*`, `OTHER`, `CLASS=[/GROUP=][/NUMBER=]`, `` = `n` | `lo..hi`) + the `alarm` object = ONE condition at the RawPath (either alone is REFUSED by the factory, so editor/save path/CSV/deploy agree; data type fixed `Boolean`). Opt-in per tag, no driver flag (FOCAS #647 precedent); `Alarms/*` diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md index dece3503d..2ab629db0 100644 --- a/docs/drivers/Heidenhain.md +++ b/docs/drivers/Heidenhain.md @@ -66,7 +66,7 @@ enums, so the numeric-enum form/factory trap cannot occur): | `TimeoutMs` | 5000 | 100–60000; connect + per-transaction deadline | | `Access.TelemetryEnabled` | true | Off = no `R_RI` requests at all | | `Access.DncLoginEnabled` | true | Optional DNC login; a refusal never blocks reads permitted under INSPECT | -| `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists (iTNC only) | +| `Access.DataEnabled` | false | Opt-in DATA login, attempted only when a `DATA:` binding exists, and only on an iTNC 530 ([Control families](#control-families)) | | `Access.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) | | `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 | | `Polling.AlarmMs` | 2000 | 100–3600000; the alarm walk behind `ALARM:` conditions — only for a control carrying an `ALARM:` tag ([Alarm conditions](#alarm-conditions-alarm-gitea-831)) | @@ -120,7 +120,7 @@ SSH subsystem. | Folder | Leaves (type) | Source | Group | |---|---|---|---| -| `Controller` | `Model`, `NcVersion`, `PlcVersion`, `Options` (String) | `R_VR` (4 or 5 fields; SPLC skipped; empty options preserved) | Slow | +| `Controller` | `Model`, `NcVersion`, `PlcVersion`, `Options`, `Family` (String) | `R_VR` (4 or 5 fields; SPLC skipped; empty options preserved). `Family` is classified locally from `Model` — no extra request ([Control families](#control-families)) | Slow | | `Status` | `ExecutionState` (Int32), `ProgramState` (Int32) — raw native enums | `R_RI 23`, `26` | Status | | `Program` | `Main`, `Current` (String), `Line` (Int64) | `R_RI 24` | Status | | `Overrides` | `Feed`, `Spindle`, `Rapid` (Float64, percent) | `R_RI 25` (uint32 hundredths) | Status | @@ -156,6 +156,55 @@ truncates or wraps — otherwise `BadTypeMismatch`. At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`). +## Control families + +`R_VR`'s model field is classified once, in one place — `HeidenhainControlFamilies.Classify` (Contracts project, +`HeidenhainControlFamily`) — with pyLSV2's rule (MIT; `dat_cls.py` `VersionInfo.control`): strip spaces, compare +case-insensitively by prefix, `ITNC530` first. The result is published on the **`Controller/Family`** String leaf +(member name: `ITnc530`, `Tnc`, `Tnc7`, `Lathe`, `MillPlus`, `LegacyTnc`, `Unknown`), included in the Test-Connect +probe message (`TNC640 (Tnc) NC …`) and the `/hosts` diagnostics bag (`.family`). It costs no request: it is +derived from the `R_VR` reply the session already reads. `Controller/Model` keeps the raw string. + +The only code-level gate pyLSV2 applies to a request this driver makes is **DATA**: `read_data_path` returns early +on anything but an iTNC (`client.py` "does not work on non iTNC controls"). So a `DATA:` binding on any other family +— and on an `Unknown` model — is `BadNotSupported` with `Capabilities/DataLogin = Unsupported`, and **the DATA login +is never sent**. (Scope is pyLSV2's other iTNC-only gate; this driver has no scope.) Every other feature is +attempted on every family and settled by the control's own reply: `T_ER_UNKNOWN_TELE` (22) ⇒ `Unsupported`, asked +once per session, then suppressed; an access refusal ⇒ `AccessDenied`. No family pre-gates a selector — pyLSV2 +records those as "observations, not guarantees" (`docs/protocol.rst`), and a pre-gate would hide data a newer NC +version serves. + +| Family (`Controller/Family`) | Models (`R_VR` prefix) | Evidence | What the reference says | Driver behaviour | +|---|---|---|---|---| +| `ITnc530` | `iTNC530` / `iTNC 530` | **Hardware-proven** — four Hermle iTNC 530 (NC 340490 03/04, 606420 03; `evidence/heidenhain-live-2026-11-11.md`) | pyLSV2 `MILL_OLD`; DATA + scope iTNC-only; tool (51), alarms (27/28) and timers (30–34) served | All features attempted; DATA allowed (opt-in) | +| `Tnc` | `TNC640`, `TNC620`, `TNC320`, `TNC128` | Per pyLSV2, hardware-unproven | `MILL_NEW`. TNC 640 NC 340590 10 SP2: 27/28/51 and 30–34 → `T_ER_UNKNOWN_TELE`; 25/26 need DNC; 24 serves the program under INSPECT with block number 0 | `Tool/*`, `Alarms/*`, `ALARM:` conditions and `Timers/*` read `Unsupported` after one refused request; without DNC `Overrides/*` and `Status/ProgramState` are `AccessDenied` and `Program/Line` reads the control's raw 0. DATA `Unsupported`, not sent | +| `Tnc7` | `TNC7` | Per pyLSV2, hardware-unproven | `TNC7` — "behaves just a bit different again"; only keyboard/key functions are flagged (not used here) | Reply-driven (no selector observations recorded); DATA `Unsupported` | +| `Lathe` | `CNCPILOT640`, `MANUALPLUS` | Per pyLSV2, hardware-unproven | `LATHE_NEW`; protocol table: no tool (51), no error messages (27/28) | Those features settle `Unsupported` from the reply; DATA `Unsupported` | +| `MillPlus` | `MILLPLUS` (e.g. `MillPlusIT V600`) | Per pyLSV2, hardware-unproven | `MILLPLUS`; "not officially supported by any other Heidenhain tool" | Reply-driven; DATA `Unsupported` | +| `LegacyTnc` | `TNC426`, `TNC430` | **No reference** (pyLSV2 reports these `UNKNOWN`) | — | Named for the leaf only; reply-driven; DATA `Unsupported` | +| `Unknown` | anything else | — | pyLSV2 `UNKNOWN` | Reply-driven; DATA `Unsupported` | + +Decoding is family-independent per pyLSV2: `R_VR` is 4 or 5 NUL-separated fields on every family (`client.py` +`_read_version`), `R_PR` is 120 or 124 bytes with one layout (`misc.py` `decode_system_parameters`), and the `R_RI` +decoders for 22–28/51 carry no family branch. The simulator exercises every family in both `R_VR` shapes, both +`R_PR` lengths and the read-only wire set (`Simulator/ControlProfiles.cs`, `HeidenhainControlFamilyTests`) — that is +evidence for the classification and the gates, **not** for any non-iTNC control's real replies. + +**Open, needs real hardware or a request this driver deliberately does not send:** + +- **Buffer size.** pyLSV2 negotiates 512–4096-byte telegrams with `C_CC` from `R_PR`'s max block length; `C_CC` is + a configuration command, outside the allowlist. We stay at the un-negotiated legacy frame on every family (pyLSV2's own default + is 256 bytes, so a control is expected to fit replies to it); a reply over 255 bytes is a transport error + (`Oversized LSV2 response`), never a truncated value. +- **Encrypted passwords.** pyLSV2's `R_PR` exposes a password-encryption key and notes TNC 640 sends some login + passwords encrypted (scope). Whether a password-protected DNC/DATA login on a `Tnc`/`Tnc7` needs that is unknown; + INSPECT and DNC with an empty password are unaffected. +- **Which family sends the 5-field `R_VR`**, and whether `R_RI 20/21` (TNC 640 axis config/physical names, outside + our selector allowlist) would be a better axes source there. +- **Selector availability per NC version** on `Tnc`/`Tnc7`/`Lathe`/`MillPlus` (the table above is one TNC 640 + observation plus pyLSV2's test-suite gating), the `R_RI 22` axis encoding, override/tool units, and whether + `R_MB` is granted under INSPECT on any non-iTNC family. + ## Alarm conditions (`ALARM:`, Gitea #831) The control exposes one flat active-alarm list (`R_RI 27` first, `28` next — class, group, number, text; only refusal @@ -333,7 +382,9 @@ No real control was available for this port. Still open: - **.NET commissioning evidence** on a real control (the LiveIntegration gate) — browse/types/arrays through a real OPC UA client, OPC write refusal, network interruption + recovery quality, bounded traffic under load. -- Controller families/versions other than iTNC 530 (TNC 320/430/640) — unproven even in the Java reference. +- Controller families/versions other than iTNC 530 (TNC 640/620/320/128, TNC7, CNC PILOT/MANUALplus, MillPlus, + TNC 426/430) — see the [Control families](#control-families) matrix; everything there except iTNC 530 is per + pyLSV2 only. - Successful raw **`R_MB`** decoding and its Boolean encoding (denied on all four tested controls). - Physical units of axis positions / tool geometry vs. the HMI; experimental timer semantics (selectors 30–34). - DNC/DATA permission matrices per control; sustained fleet/load operation. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs index e76775e47..61f33af9e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs @@ -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"); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainControlFamily.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainControlFamily.cs new file mode 100644 index 000000000..c294da4c1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainControlFamily.cs @@ -0,0 +1,77 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// +/// The HEIDENHAIN control generation, classified from the R_VR model field. The published value of the +/// Controller/Family leaf is the member name. Only is hardware-proven; every other +/// member follows pyLSV2 (MIT) and is hardware-unproven here (docs/drivers/Heidenhain.md §Control families). +/// +public enum HeidenhainControlFamily +{ + /// A model string no rule recognises. Treated like a non-iTNC: no iTNC-only feature is attempted. + Unknown, + + /// iTNC 530 (pyLSV2 ControlType.MILL_OLD) — the only family proven on hardware. + ITnc530, + + /// TNC 640 / 620 / 320 / 128 (pyLSV2 ControlType.MILL_NEW). + Tnc, + + /// TNC7 (pyLSV2 ControlType.TNC7: "behaves just a bit different again"). + Tnc7, + + /// CNC PILOT 640 / MANUALplus 620 lathe controls (pyLSV2 ControlType.LATHE_NEW). + Lathe, + + /// MillPlus (IT V600) — pyLSV2 ControlType.MILLPLUS, "not officially supported by any other Heidenhain tool". + MillPlus, + + /// + /// Legacy TNC 426 / 430. NOT a pyLSV2 class (pyLSV2 reports these as UNKNOWN); named here only so the + /// leaf is informative. It carries no gate of its own beyond "not an iTNC". + /// + LegacyTnc, +} + +/// The single authority that classifies a control and states which reference-gated features it has. +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), + ]; + + /// Classifies the R_VR model field (e.g. iTNC530, TNC 640, CNCpilot640). + /// The model string as the control reported it; null or blank is . + /// The family. + 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; + } + + /// + /// True when R_DP 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 + /// control is refused too, so no unrecognised control ever + /// receives a DATA login. + /// + /// The family. + /// Whether DATA is supported. + public static bool SupportsDataPath(HeidenhainControlFamily family) => family == HeidenhainControlFamily.ITnc530; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs index 4950300db..872099c32 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs @@ -151,6 +151,9 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable /// The identity of the current session's control, when connected. public string? ControllerModel { get { lock (_publication) return _session?.Identity?.Model; } } + /// The control family classified from the current session's identity, or null. + public HeidenhainControlFamily? ControllerFamily { get { lock (_publication) return _session?.Identity?.Family; } } + /// Starts the acquisition loop (idempotent). public void Start() { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs index 77293840c..b324b3e88 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs index fabd31043..359af19a2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs @@ -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) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs index e354caa35..68a3babcd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs @@ -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): diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Data.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Data.cs index 378fb3031..cb31e516d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Data.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Data.cs @@ -5,7 +5,11 @@ using System.Text; namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; /// Controller identity from R_VR. -public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options); +public sealed record Lsv2Versions(string Model, string Nc, string Plc, string Options) +{ + /// The control family derived from (computed, no backing field: equality and the #831 identity-change signal are unchanged). + public HeidenhainControlFamily Family => HeidenhainControlFamilies.Classify(Model); +} /// Program stack from R_RI 24. public sealed record Lsv2Program(long Line, string Main, string Current); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainControlFamilyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainControlFamilyTests.cs new file mode 100644 index 000000000..4d131a233 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainControlFamilyTests.cs @@ -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; + +/// +/// 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 ). +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainControlFamilyTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public static TheoryData Profiles() + { + var d = new TheoryData(); + 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()) + 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(); + 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 { 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 { 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 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 Tags { get; init; } + + public Task> ReadAsync() => Session.ReadBatchAsync(Tags, Ct); + + public static async Task OpenAsync( + SimulatedControl control, IReadOnlyList 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(); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/ControlProfiles.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/ControlProfiles.cs new file mode 100644 index 000000000..182e7d78c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/ControlProfiles.cs @@ -0,0 +1,159 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +/// How a simulated family answers the R_RI selectors the driver reads. +public enum SimSelectorBehaviour +{ + /// Every read selector answers (the iTNC 530 shape, hardware-captured). + All, + + /// + /// The TNC 640 observation (pyLSV2 docs/protocol.rst "Differences between different control versions", + /// NC 340590 10 SP2): 27/28/51 and 30–34 refuse T_ER_UNKNOWN_TELE (22); 25/26 need DNC; 24 serves the + /// program without DNC but with block number 0. + /// + TncObserved, + + /// + /// 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. + /// + CoreOnly, +} + +/// One simulated HEIDENHAIN control family. +/// Test display name. +/// The R_VR model field. +/// The R_VR NC software field. +/// The family the driver must classify it as. +/// Which R_RI selectors answer. +/// True to send the 5-field R_VR (model, NC, PLC, SPLC, options). +/// Where the profile's strings come from. +public sealed record ControlProfile( + string Name, string Model, string Nc, HeidenhainControlFamily Family, SimSelectorBehaviour Selectors, + bool FiveFieldVersion, string Source) +{ + public override string ToString() => Name; +} + +/// +/// 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. +/// +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 All { get; } = + [ITnc530, ITnc530Spaced, Tnc640, Tnc620, Tnc320, Tnc128, Tnc7, CncPilot640, ManualPlus620, MillPlus, Tnc430, Unrecognised]; + + /// The R_VR reply for a profile. + 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"); + + /// A simulated control speaking the profile. DataPaths["/x"] is populated for every family, so a gate — not the simulator — is what keeps non-iTNC DATA off the wire. + 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()]; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.cs index 6c9ce06ce..ad4d4e0d1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Simulator/SimulatedControl.cs @@ -23,7 +23,7 @@ public sealed record SimReply(SimReplyKind Kind, string? Command = null, byte[]? } /// -/// 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: ) speaking the six allowlisted LSV2 requests. Anything else is /// recorded as a violation and refused — the driver must never produce one. /// public sealed class SimulatedControl