Merge branch 'worktree-agent-a6ace38a85443d6d6' into heidenhain-pylsv2-parity
This commit is contained in:
@@ -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=<r>[/GROUP=<r>][/NUMBER=<r>]`, `<r>` = `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/*`
|
||||
|
||||
@@ -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 (`<host>.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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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