diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 88aba9960..a29393667 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -353,6 +353,16 @@ 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). + `R_RI` decoders are at parity with pyLSV2 (`HeidenhainPyLsv2ParityTests`); enum NAMES are additive String leaves + (`Status/*StateName`, `Tool/AxisName`) — never change the raw Int32 leaves (OEE consumers key on them). Refusals + 52/72/58/56/57 map to specific statuses but only 22/24/23/49 are `IsStable`; only 57 ends the alarm walk. + Raw `R_MB` is refused on real iTNC 530s because (per pyLSV2) it needs the **PLCDEBUG** login, a write-capable role + (pyLSV2 also uses it for `C_MC` machine-parameter writes). Enabling it is an owner decision. Do not add it to work + around the refusal; `DATA:/PLC/memory/…` reads the same operands. PLC/DATA decoding is pyLSV2-parity, except + pyLSV2's R_PR count units, which the real captures contradict (§"pyLSV2 correspondence"). - **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..8cec0cc62 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,14 +120,14 @@ 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 | -| `Status` | `ExecutionState` (Int32), `ProgramState` (Int32) — raw native enums | `R_RI 23`, `26` | Status | +| `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, unchanged; `ExecutionStateName`, `ProgramStateName` (String) — their names per pyLSV2 ([below](#runtime-info-decoding--parity-with-pylsv2)) | `R_RI 23`, `26` (one read serves raw + name) | Status | | `Program` | `Main`, `Current` (String), `Line` (Int64) | `R_RI 24` | Status | | `Overrides` | `Feed`, `Spindle`, `Rapid` (Float64, percent) | `R_RI 25` (uint32 hundredths) | Status | -| `Axes` | `Names` (String[]), `Positions` (Float64[]) — aligned, controller units | `R_RI 22` | Status | -| `Tool` | `Number` (Int64), `Index`, `AxisCode` (Int32), `Length`, `Radius` (Float64) | `R_RI 51` (8-byte reply ⇒ geometry `BadNotSupported`, never 0) | Slow | +| `Axes` | `Names` (String[]), `Positions` (Float64[]) — aligned; no unit on the wire (pyLSV2 assumes none either) | `R_RI 22` | Status | +| `Tool` | `Number` (Int64), `Index`, `AxisCode` (Int32), `AxisName` (String, `X`/`Y`/`Z`/`Unknown` per pyLSV2), `Length`, `Radius` (Float64, no unit on the wire) | `R_RI 51` (8-byte reply ⇒ geometry `BadNotSupported`, never 0; `AxisName` still Good) | Slow | | `Alarms` | `Classes`, `Groups`, `Numbers` (Int32[]), `Messages` (String[]), `Count` (Int32) | `R_RI 27` then `28`; only refusal 57 ends the list | Slow | -| `Timers` | `NcUptime`, `MachineUptime`, `MachineRuntime`, `Spindle0Runtime`, `Spindle1Runtime` (Int64, raw) | `R_RI 30–34`, **disabled by default** | Slow | +| `Timers` | `NcUptime`, `MachineUptime`, `MachineRuntime`, `Spindle0Runtime`, `Spindle1Runtime` (Int64, raw uint32 **seconds** per pyLSV2 — lifetime counters, hardware-unproven here) | `R_RI 30–34`, **disabled by default** | Slow | | `Capabilities` | one String per feature + `PlcLayout`, `DncLogin`, `DataLogin` | local, never a wire read | Slow | Array leaves require `"isArray": true` (variable length); the typed editor and browse commit write it. Capability @@ -141,9 +141,10 @@ the next populated advertised base is a defensive cap. Bit and STRING counts are | Area | Native | Allowed `dataType` | |---|---|---| -| `MARKER`, `INPUT`, `OUTPUT`, `COUNTER`, `TIMER` | one byte per Boolean (0/1 only) | Boolean | +| `MARKER`, `INPUT`, `OUTPUT`, `COUNTER`, `TIMER` | one byte per Boolean: `00` false, `01`/`FF` true (anything else ⇒ `BadDecodingError`) | Boolean | | `BYTE` / `WORD` / `DWORD` | signed int8 / int16le / int32le | Int16 (BYTE, WORD only), Int32, Int64 | | `INPUT_WORD`, `OUTPUT_WORD` | uint16le | Int32, Int64 | +| `INPUT_DWORD`, `OUTPUT_DWORD` | signed int32le over the input/output-word pool (per pyLSV2, hardware-unproven) | Int32, Int64 | | `STRING` | advertised fixed width, terminated Latin-1 (too wide ⇒ error, never truncated) | String | Example: `PLC:WORD:4999` → `R_MB` at `base + 4999×2`. Note that `DATA:/PLC/memory/W/9998` uses a native **byte @@ -156,6 +157,135 @@ truncates or wraps — otherwise `BadTypeMismatch`. At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`). +**PLC bit `FF`.** Accepting `FF` for a raw `R_MB` bit (since 2026-11-12) follows the evidence, not a capture of `R_MB` itself: the same one-byte +operands read through `R_DP` return `FF` for a set input/output on a real iTNC 530 (`I.999`/`O.999` in +`Fixtures/plc-count-units.properties`), and pyLSV2 decodes these areas as "any non-zero is true". `02`–`FE` stay +malformed. Previously an `FF` raw bit read `BadDecodingError`. + +### pyLSV2 correspondence (per pyLSV2, hardware-unproven) + +[pyLSV2](https://github.com/drunsinn/pyLSV2) (MIT) is the other open implementation of these requests. Checked +2026-11-12; where it disagrees with a real-control capture, the capture wins. + +| Item | pyLSV2 | This driver | Verdict | +|---|---|---|---| +| `R_PR` field offsets (`misc.py:decode_system_parameters`, `!14L8B8L…`, 120 or 124 bytes) | (base, count) pairs at 0/8/16/24/32/40/48, string length byte at 56, input words 64, output words 72 | same; trailing version/buffer/key fields unused | agree | +| `R_PR` numeric count **units** (`client.py:read_plc_memory`) | BYTE max = count×2, WORD/INPUT_WORD/OUTPUT_WORD max = count, DWORD and I/O DWORD max = count/4 | every numeric count is a byte capacity | **conflict — capture wins**: the three iTNC 530 controls refuse `B10000`/`W10000`/`IW1000`, and each base + count is exactly the next advertised base | +| `R_MB` address | `start + first_element × width` | `base + INDEX × width` | agree | +| `R_MB` count byte | `elements × width`, ≤ ⌊255/width⌋ − 1 elements per telegram | one element per request (`width` bytes, ≤ 247) | agree (STRING widths above 247 are refused rather than sent) | +| Signedness / endianness | BYTE ``, `I`, `O`, +`B`, `S` are entry/element numbers, while `W`, `D`, `IW`, `OW`, `ID`, `OD` are **byte** +addresses (pyLSV2 divides by 2 or 4 and silently truncates an unaligned one). No `T`/`C` operands. The correspondence +is: `M n` = `PLC:MARKER:n`, `I n` = `PLC:INPUT:n`, `O n` = `PLC:OUTPUT:n`, `B n` = `PLC:BYTE:n`, `S n` = +`PLC:STRING:n`, `W 2k` = `PLC:WORD:k`, `D 4k` = `PLC:DWORD:k`, `IW 2k` = `PLC:INPUT_WORD:k`, `OW 2k` = +`PLC:OUTPUT_WORD:k`, `ID 4k` = `PLC:INPUT_DWORD:k`, `OD 4k` = `PLC:OUTPUT_DWORD:k`. The `DATA:/PLC/memory//` +paths use the same byte numbering as the PLC program. The driver does **not** accept the native operands as aliases: +a `PLC:` field in which `W8` means byte 8 but `WORD:8` means byte 16 is exactly the confusion the element-index rule +exists to avoid. + +**Open question — PLC strings.** pyLSV2 returns the whole fixed-width buffer with NULs stripped, so any bytes after +the terminator stay in the text. This driver refuses anything other than NUL padding after the terminator +(`BadDecodingError`). If a real control leaves stale bytes after a shorter string, the right fix is "text up to the +first NUL", not pyLSV2's behaviour. Hardware-gated with raw `R_MB`. + +### Why raw `R_MB` is refused (PLCDEBUG) + +Raw `R_MB` was refused on every real iTNC 530 tried. pyLSV2 explains why: `read_plc_memory` and `read_plc_address` +both send `A_LG PLCDEBUG` before `R_MB` (pyLSV2's `const.py` itself hedges: "requires INSPECT (PLCDEBUG??)"), and +this driver never logs in as PLCDEBUG. pyLSV2 describes PLCDEBUG as "enables **write** access to PLC, requires +password", and uses the same role for `C_MC` (`set_machine_parameter`, a machine-parameter **write**). So PLCDEBUG is +a write-capable role, not a read-only one. pyLSV2's own default "safe mode" refuses the PLCDEBUG login (it allows +INSPECT/FILE/MONITOR only), and its PLC-memory demo turns safe mode off. pyLSV2 sends PLCDEBUG with an empty password, +which can only work on a control that has no PLCDEBUG password set (typically a programming station). A production +control has a password that would have to be entered here. **Owner decision:** raw PLC memory stays unavailable +unless the owner accepts a write-capable LSV2 session. `Lsv2ReadOnlyPolicy` would still refuse every write request, +but the controller would no longer be enforcing read-only. Until then, use `DATA:/PLC/memory/...` bindings, which +read the same operands under the DATA role. The decoders above are already pyLSV2-parity, so nothing else changes +if a role is ever enabled. All of this is per pyLSV2 and has not been tested on hardware. + +## 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. + +## Runtime-info decoding — parity with pyLSV2 + +Every `R_RI` decoder was compared byte-for-byte with pyLSV2 (MIT, commit `97a3d691`: `client.py` `axes_location`, +`execution_state`, `program_status`, `program_stack`, `override_state`, `get_error_messages`, `spindle_tool_status`; +`misc.py` decoders; `const.py` `ParRRI`, `ExecState`, `PgmState`, `LSV2StatusCode`) and the captured iTNC 530 +replies were run through pyLSV2 itself — our decoders produce the same values (`HeidenhainPyLsv2ParityTests`). +**No decode disagreement was found**, so no published value changed. + +| Selector | Layout (ours = pyLSV2) | Notes | +|---|---|---| +| 22 axes | byte 0 opaque, count byte, `count` value strings, `count` names | We are stricter: unique non-blank names, finite values, ≤ 64 axes, no trailing bytes | +| 23 / 26 | uint16be | Names: `Manual, Mdi, PassReferences, SingleStep, Automatic, Undefined` / `Started, Stopped, Finished, Cancelled, Interrupted, Error, ErrorCleared, Idle, Undefined`; any other code ⇒ `Unknown` (pyLSV2 raises) | +| 24 | uint32be line, main, current | Strict on NUL padding. pyLSV2 notes a TNC 640 serves 24 without DNC but with line 0 | +| 25 | 3 × uint32be hundredths of percent: feed, spindle, rapid | — | +| 27 / 28 | uint16be class, uint16be group, int32be number, text | Only 57 ends the walk (pyLSV2 too). Trailing blanks in the text are **kept** (pyLSV2 strips them — see [Hardware-gated](#hardware-gated--not-yet-proven)) | +| 51 | uint32be number, uint16be index, uint16be axis (0 X, 1 Y, 2 Z), 8 or 24 bytes; geometry float64 **little**-endian | Live-proven (L 138.1506 on Hermle70) | +| 30–34 | uint32be, seconds | pyLSV2: NC uptime (+1/s), machine uptime, machine runtime (grows only while a program runs), spindle 0 / 1 runtime (0 if unused); observed there on iTNC 530 340490 02/04 and 606421 01, refused `T_ER_UNKNOWN_TELE` on a TNC 640. `TimersEnabled` stays **false** by default | + +Refusal codes now map to the most specific status (table under [Quality](#quality)) and every refusal log line and +Test-Connect message names the code (`T_ER_NO_PRIV`, …) from pyLSV2's `LSV2StatusCode` table +(`Lsv2RefusalCodes`). Stability is unchanged — only 22/24/23/49 (and a credential problem) suppress a feature for +the session; every newly-named code is retried, as `BadUnexpectedError` was. + +**Not adopted (outside the read-only allowlist):** pyLSV2 also reads `R_RI 20/21` (axis configuration / physical +names, layouts only partly known), `R_DT` (control clock, DIAGNOSTICS login) and the scope/file/key functions. None is +added; the allowlist is unchanged. + ## 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 @@ -259,8 +389,12 @@ class of the alarm it last showed (and a rebuild carries it). The condition mess | Condition | Value status / capability | |---|---| -| Refusal 22/24 | `BadNotSupported` / `Unsupported` — suppressed for the session | +| Refusal 22/24 (`T_ER_UNKNOWN_TELE` / `T_ER_WRONG_PARA`) | `BadNotSupported` / `Unsupported` — suppressed for the session | | Refusal 23/49 | `BadUserAccessDenied` / `AccessDenied` — suppressed for the session, except a provisional denial while the optional DNC login is temporarily failing (retried) | +| Refusal 52/72 (`T_ER_BAD_MEMADR` / `T_ER_OUT_OF_RANGE`) | `BadOutOfRange` / `InvalidData` — retried next poll | +| Refusal 58 (`T_ER_ACCESS_TIMEOUT`) | `BadTimeout` / `Unknown` — retried next poll | +| Refusal 56 (`T_ER_OPERATING_MODE`) | `BadInvalidState` / `Unknown` — retried next poll | +| Refusal 57 (`T_ER_NO_NEXT_ERROR`) outside the alarm walk | `BadNoData` / `Unknown` — retried (inside the walk it is the end of the list) | | Other refusal | `BadUnexpectedError` / `Unknown` — retried next poll | | Malformed complete record (length, terminator, NaN, duplicate axis, …) | `BadDecodingError` / `InvalidData` | | Binding type mismatch | `BadTypeMismatch` | @@ -333,10 +467,21 @@ 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. -- 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). +- 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. `R_DP` on non-iTNC families is gated off (pyLSV2 restricts `read_data_path` to iTNC). +- Successful raw **`R_MB`** decoding and its Boolean encoding. It was denied on all four tested controls, and per + pyLSV2 that is because it needs the write-capable PLCDEBUG role + ([why](#why-raw-r_mb-is-refused-plcdebug)) — which this read-only driver will not use. The `FF`-true bit, + `INPUT_DWORD`/`OUTPUT_DWORD` and PLC-string trailing bytes are per pyLSV2 or `R_DP` evidence, not `R_MB` captures. +- Physical units of axis positions / tool geometry vs. the HMI — the wire carries no unit and pyLSV2 assumes none, so + only a control can settle it. Timer semantics (selectors 30–34) are now **per pyLSV2** (uint32be seconds, + lifetime counters) but still unproven by this driver on hardware. +- Execution / program-state **names** are per pyLSV2 (its `PgmState` table comes from a public forum thread); the + 2026-11-11 iTNC 530 values (running = 4 Automatic + 0 Started; idle = 7 Idle) are consistent with them. - DNC/DATA permission matrices per control; sustained fleet/load operation. +- Whether any control pads `R_RI 22/24/27` strings with trailing blanks. pyLSV2 strips them (`ba_to_ustr`); this + driver keeps them, because an alarm text is part of a condition's identity and the captured records carry none. ## Deliberate exclusions diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs index 4c719b996..fb2d89ba5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs @@ -17,6 +17,15 @@ public enum HeidenhainPlcArea String, InputWord, OutputWord, + + /// + /// Signed int32le view over the INPUT_WORD region (element index × 4 + the input-word base) — per pyLSV2 (MIT) + /// const.py:MemoryType.INPUT_DWORD / client.py:read_plc_memory, hardware-unproven. + /// + InputDword, + + /// Signed int32le view over the OUTPUT_WORD region — per pyLSV2 (MIT) const.py:MemoryType.OUTPUT_DWORD, hardware-unproven. + OutputDword, } /// What an address resolves to. @@ -85,6 +94,8 @@ public sealed record HeidenhainAddress( ["STRING"] = HeidenhainPlcArea.String, ["INPUT_WORD"] = HeidenhainPlcArea.InputWord, ["OUTPUT_WORD"] = HeidenhainPlcArea.OutputWord, + ["INPUT_DWORD"] = HeidenhainPlcArea.InputDword, + ["OUTPUT_DWORD"] = HeidenhainPlcArea.OutputDword, }; /// The wire spelling of a PLC area (MARKER, INPUT_WORD, …). @@ -101,11 +112,15 @@ public sealed record HeidenhainAddress( public static bool IsBitArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Marker or HeidenhainPlcArea.Input or HeidenhainPlcArea.Output or HeidenhainPlcArea.Counter or HeidenhainPlcArea.Timer; - /// True for the integer areas (BYTE/WORD/DWORD signed; INPUT_WORD/OUTPUT_WORD unsigned 16-bit). + /// + /// True for the integer areas (BYTE/WORD/DWORD and INPUT_DWORD/OUTPUT_DWORD signed; INPUT_WORD/OUTPUT_WORD + /// unsigned 16-bit). + /// /// The area. /// Whether it is an integer area. public static bool IsIntegerArea(HeidenhainPlcArea area) => area is HeidenhainPlcArea.Byte or HeidenhainPlcArea.Word - or HeidenhainPlcArea.Dword or HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord; + or HeidenhainPlcArea.Dword or HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord + or HeidenhainPlcArea.InputDword or HeidenhainPlcArea.OutputDword; /// /// Parses an address. Never throws; explains a rejection. 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..fe831465e 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 @@ -59,7 +59,12 @@ public static class HeidenhainCatalog /// Feature id reported for the opt-in DATA login. public const string DataLoginFeature = "DataLogin"; - /// Experimental timer leaf names, in selector order (30..34). + /// + /// Experimental timer leaf names, in selector order (30..34). Per pyLSV2 (MIT) const.py:ParRRI and + /// docs/protocol.rst each is a uint32be lifetime counter in SECONDS (NC uptime +1 s while the NC runs, + /// machine uptime, machine runtime that grows only while a program runs, spindle 0 / spindle 1 runtime — 0 when + /// a second spindle is unused); not the runtime of the current program. Hardware-unproven here. + /// public static readonly IReadOnlyList TimerNames = ["NcUptime", "MachineUptime", "MachineRuntime", "Spindle0Runtime", "Spindle1Runtime"]; @@ -102,8 +107,13 @@ 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"); + // Additive names for the raw enums above (per pyLSV2; HeidenhainStateNames). Same feature = same wire read. + Add("Status", DriverDataType.String, false, HeidenhainPollGroup.Status, "Execution", "ExecutionStateName"); + Add("Status", DriverDataType.String, false, HeidenhainPollGroup.Status, "ProgramState", "ProgramStateName"); Add("Program", DriverDataType.String, false, HeidenhainPollGroup.Status, "Program", "Main", "Current"); Add("Program", DriverDataType.Int64, false, HeidenhainPollGroup.Status, "Program", "Line"); Add("Overrides", DriverDataType.Float64, false, HeidenhainPollGroup.Status, "Overrides", "Feed", "Spindle", "Rapid"); @@ -112,6 +122,7 @@ public static class HeidenhainCatalog Add("Tool", DriverDataType.Int64, false, HeidenhainPollGroup.Slow, "Tool", "Number"); Add("Tool", DriverDataType.Int32, false, HeidenhainPollGroup.Slow, "Tool", "Index", "AxisCode"); Add("Tool", DriverDataType.Float64, false, HeidenhainPollGroup.Slow, "Tool", "Length", "Radius"); + Add("Tool", DriverDataType.String, false, HeidenhainPollGroup.Slow, "Tool", "AxisName"); Add("Alarms", DriverDataType.Int32, true, HeidenhainPollGroup.Slow, "Alarms", "Classes", "Groups", "Numbers"); Add("Alarms", DriverDataType.String, true, HeidenhainPollGroup.Slow, "Alarms", "Messages"); Add("Alarms", DriverDataType.Int32, false, HeidenhainPollGroup.Slow, "Alarms", "Count"); 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.Contracts/HeidenhainStateNames.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainStateNames.cs new file mode 100644 index 000000000..995b70bec --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainStateNames.cs @@ -0,0 +1,53 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +/// +/// Names for the raw native enums the control reports through R_RI 23 (execution mode), R_RI 26 +/// (program state) and the spindle-axis code of R_RI 51. They back the additive …Name String leaves +/// only — the raw Int32 leaves (Status/ExecutionState, Status/ProgramState, Tool/AxisCode) are +/// unchanged and stay the value OEE consumers key on. +/// +/// +/// Provenance. The tables are the enums the open-source pyLSV2 library defines — per pyLSV2 (MIT) +/// const.py:ExecState, const.py:PgmState and misc.py:decode_tool_info (commit +/// 97a3d691), spelled in this catalog's PascalCase. pyLSV2's own PgmState table comes from a public +/// forum thread, not a HEIDENHAIN document. The four iTNC 530 controls of the 2026-11-11 commissioning run +/// reported values consistent with it (a running program read execution 4 = Automatic with program state +/// 0 = Started and an advancing line; an idle control read 1 = Mdi / 4 = Automatic with 7 = Idle), which +/// corroborates — but does not prove — the tables. +/// A code outside a table publishes , never an invented name (pyLSV2 itself raises on an +/// out-of-table value). The raw leaf still carries the code. +/// +public static class HeidenhainStateNames +{ + /// The name published for a code no table defines. + public const string Unknown = "Unknown"; + + /// R_RI 23 execution mode (per pyLSV2 ExecState). + public static IReadOnlyList ExecutionStates { get; } = + ["Manual", "Mdi", "PassReferences", "SingleStep", "Automatic", "Undefined"]; + + /// R_RI 26 state of the selected program (per pyLSV2 PgmState). + public static IReadOnlyList ProgramStates { get; } = + ["Started", "Stopped", "Finished", "Cancelled", "Interrupted", "Error", "ErrorCleared", "Idle", "Undefined"]; + + /// R_RI 51 tool / spindle axis code (per pyLSV2 decode_tool_info: 0 X, 1 Y, 2 Z). + public static IReadOnlyList ToolAxes { get; } = ["X", "Y", "Z"]; + + /// The name of an execution-mode code. + /// The raw R_RI 23 value. + /// The name, or . + public static string ExecutionState(int code) => Lookup(ExecutionStates, code); + + /// The name of a program-state code. + /// The raw R_RI 26 value. + /// The name, or . + public static string ProgramState(int code) => Lookup(ProgramStates, code); + + /// The name of a tool-axis code. + /// The raw axis code of R_RI 51. + /// The axis letter, or . + public static string ToolAxis(int code) => Lookup(ToolAxes, code); + + private static string Lookup(IReadOnlyList table, int code) => + code >= 0 && code < table.Count ? table[code] : Unknown; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs index 3c41e41b3..9620166b4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs @@ -52,9 +52,12 @@ public static class HeidenhainBindingCodec if (HeidenhainAddress.IsBitArea(area)) { Lsv2Codec.Length(bytes, 1); - // Raw R_MB Boolean encoding is unverified on real controls (R_MB was denied on all four tested); - // the reference accepts only 0/1 here — FF is accepted only for DATA path Booleans. - if (bytes[0] is not (0 or 1)) throw new Lsv2DecodeException("Invalid PLC boolean."); + // Raw R_MB Boolean encoding is unverified on real controls (R_MB was denied on all four tested). The + // SAME one-byte-per-entry operands read through R_DP return FF for a set input/output on a real + // iTNC 530 (plc-count-units.properties hermle59 I.999/O.999), and pyLSV2 (MIT) + // client.py:read_plc_memory decodes these areas with struct "?" (any non-zero = true). Accept the + // observed 00/01/FF — anything else is still malformed, never repaired into a Good value. + if (bytes[0] is not (0 or 1 or 0xFF)) throw new Lsv2DecodeException("Invalid PLC boolean."); value = bytes[0] != 0; } else @@ -66,6 +69,9 @@ public static class HeidenhainBindingCodec HeidenhainPlcArea.Dword => Exact(bytes, 4, b => BinaryPrimitives.ReadInt32LittleEndian(b)), HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord => Exact(bytes, 2, b => (int)BinaryPrimitives.ReadUInt16LittleEndian(b)), + // per pyLSV2 (MIT) client.py:read_plc_memory — INPUT_DWORD/OUTPUT_DWORD unpack " + Exact(bytes, 4, b => BinaryPrimitives.ReadInt32LittleEndian(b)), HeidenhainPlcArea.String => Lsv2Codec.Text(bytes), _ => throw new Lsv2DecodeException("Unknown PLC area."), }; 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..9e4b07ef1 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() { @@ -256,7 +259,7 @@ internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable // Connect / INSPECT / identity failure. Never log payloads or credentials: type + refusal code only. var reason = ex switch { - Lsv2RefusalException r => $"INSPECT/identity refused (code {r.Code})", + Lsv2RefusalException r => $"INSPECT/identity refused (code {r.Code} {Lsv2RefusalCodes.Name(r.Code)})", Lsv2DecodeException => "Malformed identity record", _ => ex.Message, }; 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..4d19bf9df 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) { @@ -81,7 +81,7 @@ public sealed class HeidenhainDriverProbe : IDriverProbe } catch (Lsv2RefusalException ex) { - return new(false, $"Reachable at {target} but the control refused INSPECT/version (code {ex.Code}).", null); + return new(false, $"Reachable at {target} but the control refused INSPECT/version (code {ex.Code} {Lsv2RefusalCodes.Name(ex.Code)}).", null); } catch (Lsv2DecodeException) { 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..a8f7fa36b 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): @@ -455,6 +456,7 @@ internal sealed class HeidenhainSession : IAsyncDisposable "Number" => (object)t.Number, "Index" => (object)t.Index, "AxisCode" => (object)t.Axis, + "AxisName" => HeidenhainStateNames.ToolAxis(t.Axis), "Length" => (object?)t.Length, _ => (object?)t.Radius, }; @@ -468,7 +470,13 @@ internal sealed class HeidenhainSession : IAsyncDisposable _ => (object)list.Select(x => x.Number).ToArray(), }; case int i: - return i; + // The …Name leaves share the raw leaf's one wire read; the raw Int32 leaf is published unchanged. + return name switch + { + "ExecutionStateName" => HeidenhainStateNames.ExecutionState(i), + "ProgramStateName" => HeidenhainStateNames.ProgramState(i), + _ => (object)i, + }; case long l: return l; default: diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs index 3b4fb4497..6d46b439b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainStatus.cs @@ -41,13 +41,33 @@ public static class HeidenhainStatus /// Authored but not yet acquired. public const uint BadWaitingForInitialData = 0x80320000u; - /// Maps a controller refusal code to a value status (22/24 unsupported, 23/49 denied, else unexpected). + /// Refusal 58 (T_ER_ACCESS_TIMEOUT): the control timed out reaching the data (retried). + public const uint BadTimeout = 0x800A0000u; + + /// Refusal 56 (T_ER_OPERATING_MODE): not allowed in the control's current mode (retried). + public const uint BadInvalidState = 0x80AF0000u; + + /// Refusal 57 (T_ER_NO_NEXT_ERROR) outside the alarm walk, which consumes it as "end of list". + public const uint BadNoData = 0x809B0000u; + + /// + /// Maps a controller refusal code to a value status. Code names per pyLSV2 (MIT) + /// const.py:LSV2StatusCode (): 22 UNKNOWN_TELE / 24 WRONG_PARA ⇒ + /// unsupported; 23 NO_PRIV / 49 NO_LOGIN ⇒ access denied; 52 BAD_MEMADR / 72 OUT_OF_RANGE ⇒ out of range; + /// 58 ACCESS_TIMEOUT ⇒ timeout; 56 OPERATING_MODE ⇒ invalid state; 57 NO_NEXT_ERROR ⇒ no data; anything else + /// unexpected. Only the first two are (suppressed for the session) — every other code is + /// retried on the next poll, exactly as before the specific mapping. + /// /// The refusal code. /// The status. public static uint FromRefusal(int code) => code switch { - 22 or 24 => BadNotSupported, - 23 or 49 => BadUserAccessDenied, + Lsv2.Lsv2RefusalCodes.UnknownTelegram or Lsv2.Lsv2RefusalCodes.WrongParameter => BadNotSupported, + Lsv2.Lsv2RefusalCodes.NoPrivilege or Lsv2.Lsv2RefusalCodes.NoLogin => BadUserAccessDenied, + Lsv2.Lsv2RefusalCodes.BadMemoryAddress or Lsv2.Lsv2RefusalCodes.OutOfRange => BadOutOfRange, + Lsv2.Lsv2RefusalCodes.AccessTimeout => BadTimeout, + Lsv2.Lsv2RefusalCodes.OperatingMode => BadInvalidState, + Lsv2.Lsv2RefusalCodes.NoNextError => BadNoData, _ => BadUnexpectedError, }; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Codec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Codec.cs index 97e3645df..f640e3529 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Codec.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2Codec.cs @@ -11,7 +11,7 @@ public sealed class Lsv2DecodeException(string message) : Exception(message); /// framing synchronised, so the session stays usable. /// public sealed class Lsv2RefusalException(int type, int code) - : Exception($"LSV2 controller refusal type={type} code={code}") + : Exception($"LSV2 controller refusal type={type} code={code} ({Lsv2RefusalCodes.Name(code)})") { /// The refusal type byte. public int Type { get; } = type; 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..fabc2254b 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); @@ -46,21 +50,30 @@ public static class Lsv2Data return new Lsv2Versions(parts[0], parts[1], parts[2], parts[^1]); } - /// Exactly two bytes, uint16be. + /// + /// Exactly two bytes, uint16be — R_RI 23 execution mode / R_RI 26 program state, per pyLSV2 (MIT) + /// client.py:execution_state / program_status (struct "!H"). + /// public static int U16(ReadOnlySpan bytes) { Lsv2Codec.Length(bytes, 2); return BinaryPrimitives.ReadUInt16BigEndian(bytes); } - /// Exactly four bytes, uint32be. + /// + /// Exactly four bytes, uint32be — the R_RI 30–34 lifetime timers, in SECONDS per pyLSV2 (MIT) + /// const.py:ParRRI + docs/protocol.rst (hardware-unproven here). Published raw, never rescaled. + /// public static long U32(ReadOnlySpan bytes) { Lsv2Codec.Length(bytes, 4); return BinaryPrimitives.ReadUInt32BigEndian(bytes); } - /// uint32be line, main NUL, current NUL, optional NUL padding only. + /// + /// uint32be line, main NUL, current NUL, optional NUL padding only — per pyLSV2 (MIT) + /// misc.py:decode_stack_info, but strict (pyLSV2 ignores what follows the second string). + /// public static Lsv2Program Program(ReadOnlySpan bytes) { if (bytes.Length < 6) throw Bad(); @@ -69,7 +82,10 @@ public static class Lsv2Data return new Lsv2Program(BinaryPrimitives.ReadUInt32BigEndian(bytes), parts[0], parts[1]); } - /// Three uint32be hundredths of percent: feed, spindle, rapid. + /// + /// Three uint32be hundredths of percent, in the order feed, spindle, rapid — per pyLSV2 (MIT) + /// misc.py:decode_override_state (/ 100). + /// public static double[] Overrides(ReadOnlySpan bytes) { Lsv2Codec.Length(bytes, 12); @@ -81,7 +97,12 @@ public static class Lsv2Data ]; } - /// 8-byte identity-only or 24-byte reply with two float64le geometry values. + /// + /// 8-byte identity-only or 24-byte reply: uint32be number, uint16be index, uint16be axis code (0 X, 1 Y, 2 Z), + /// then float64 LITTLE-endian length and radius — per pyLSV2 (MIT) misc.py:decode_tool_info ("<d"; + /// the mixed endianness is real: a live iTNC 530 read L 138.1506 / R −0.0101 through this decoder, 2026-11-11). + /// No unit is on the wire and pyLSV2 assumes none either; none is inferred here. + /// public static Lsv2Tool Tool(ReadOnlySpan bytes) { if (bytes.Length != 8 && bytes.Length != 24) throw Bad(); @@ -98,7 +119,11 @@ public static class Lsv2Data return new Lsv2Tool(number, index, axis, length, radius); } - /// uint16be class, uint16be group, int32be number, terminated text. + /// + /// uint16be class, uint16be group, int32be (signed) number, terminated text — per pyLSV2 (MIT) + /// misc.py:decode_error_message. Stricter on the text: it must be NUL-terminated with NUL padding only, + /// and trailing blanks are kept (pyLSV2 ba_to_ustr strips them; the text is part of a condition's identity). + /// public static Lsv2Alarm Alarm(ReadOnlySpan bytes) { if (bytes.Length < 9) throw Bad(); @@ -109,7 +134,12 @@ public static class Lsv2Data Lsv2Codec.Text(bytes[8..])); } - /// Opaque byte, uint8 count, count numeric strings, count unique non-blank names. + /// + /// Opaque byte, uint8 count, count numeric strings, count unique non-blank names — per pyLSV2 (MIT) + /// misc.py:decode_axis_location (values first, then names; byte 0 undocumented there too). Positions are + /// the control's display strings parsed as-is: no unit is on the wire and pyLSV2 assumes none (linear vs rotary + /// axes and mm vs inch are neither reported nor inferred here). + /// public static Lsv2Axes Axes(ReadOnlySpan bytes) { if (bytes.Length < 2) throw Bad(); @@ -136,6 +166,15 @@ public static class Lsv2Data /// B9999/W9998/D9996 are the last valid values for a count of 10000); bit and string counts are entries. /// The next populated distinct area remains a defensive cap for inconsistent layouts. /// + /// + /// Field offsets agree with pyLSV2 (MIT) misc.py:decode_system_parameters (!14L8B8L…): eight + /// uint32be (base, count) pairs for markers/inputs/outputs/counters/timers/words/strings at 0–55, the string + /// length byte at 56, input words at 64, output words at 72; the trailing LSV2-version/buffer/key fields are + /// not used. pyLSV2's count UNITS conflict with the real captures and are deliberately not followed: + /// client.py:read_plc_memory caps BYTE at count×2, WORD and INPUT_WORD/OUTPUT_WORD at count (as if the + /// count were words) and only DWORD at count/4 — the iTNC 530 replies in plc-count-units.properties + /// refuse B10000, W10000 and IW1000, and every advertised base + count lands exactly on the next base. + /// public static Lsv2Layout Layout(ReadOnlySpan bytes) { if (bytes.Length != 120 && bytes.Length != 124) throw Bad(); @@ -149,6 +188,9 @@ public static class Lsv2Data Add(regions, bytes, HeidenhainPlcArea.String, 48, stringWidth, stringWidth); Add(regions, bytes, HeidenhainPlcArea.InputWord, 64, 1, 2); Add(regions, bytes, HeidenhainPlcArea.OutputWord, 72, 1, 2); + // per pyLSV2 (MIT) client.py:read_plc_memory — the DWORD views share the input/output-word pools. + Add(regions, bytes, HeidenhainPlcArea.InputDword, 64, 1, 4); + Add(regions, bytes, HeidenhainPlcArea.OutputDword, 72, 1, 4); CapNumericRegionsAtNextArea(regions); return new Lsv2Layout(regions); } @@ -157,7 +199,11 @@ public static class Lsv2Data { // Same-base BYTE/WORD/DWORD views intentionally share an area, not a boundary. var starts = new SortedSet(regions.Values.Where(r => r.Bytes > 0 && r.Width > 0).Select(r => r.Base)); - foreach (var area in new[] { HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord, HeidenhainPlcArea.OutputWord }) + foreach (var area in new[] + { + HeidenhainPlcArea.Byte, HeidenhainPlcArea.Word, HeidenhainPlcArea.Dword, HeidenhainPlcArea.InputWord, + HeidenhainPlcArea.OutputWord, HeidenhainPlcArea.InputDword, HeidenhainPlcArea.OutputDword, + }) { var region = regions[area]; var next = starts.GetViewBetween(region.Base + 1, long.MaxValue).Cast().FirstOrDefault(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2RefusalCodes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2RefusalCodes.cs new file mode 100644 index 000000000..bf2d4a5e2 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/Lsv2/Lsv2RefusalCodes.cs @@ -0,0 +1,123 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; + +/// +/// The controller refusal codes carried by a T_ER/T_BD frame, with their protocol names — for logs +/// and diagnostics only (behaviour is ). Per pyLSV2 (MIT) +/// const.py:LSV2StatusCode (telegram errors 20–99, block-transfer errors 100–200, 255); the numbers are the +/// protocol's, the names are that table's. +/// +public static class Lsv2RefusalCodes +{ + /// Unknown telegram — the control does not implement the request / selector. + public const int UnknownTelegram = 22; + + /// No privilege for the request under the current logins. + public const int NoPrivilege = 23; + + /// Wrong parameter — e.g. an R_RI selector this control does not serve. + public const int WrongParameter = 24; + + /// The required login has not been made. + public const int NoLogin = 49; + + /// Bad PLC memory address. + public const int BadMemoryAddress = 52; + + /// The control is in an operating mode that does not allow the request. + public const int OperatingMode = 56; + + /// No (next) error — the only proof that the R_RI 27/28 alarm walk reached its end. + public const int NoNextError = 57; + + /// The control timed out accessing the requested data. + public const int AccessTimeout = 58; + + /// A value out of range. + public const int OutOfRange = 72; + + private static readonly IReadOnlyDictionary Names = new Dictionary + { + [20] = "T_ER_BAD_FORMAT", + [21] = "T_ER_UNEXPECTED_TELE", + [22] = "T_ER_UNKNOWN_TELE", + [23] = "T_ER_NO_PRIV", + [24] = "T_ER_WRONG_PARA", + [25] = "T_ER_BREAK", + [30] = "T_ER_BAD_KEY", + [31] = "T_ER_BAD_FNAME", + [32] = "T_ER_NO_FILE", + [33] = "T_ER_OPEN_FILE", + [34] = "T_ER_FILE_EXISTS", + [35] = "T_ER_BAD_FILE", + [36] = "T_ER_NO_DELETE", + [37] = "T_ER_NO_NEW_FILE", + [38] = "T_ER_NO_CHANGE_ATT", + [39] = "T_ER_BAD_EMULATEKEY", + [40] = "T_ER_NO_MP", + [41] = "T_ER_NO_WIN", + [42] = "T_ER_WIN_NOT_AKTIV", + [43] = "T_ER_ANZ", + [44] = "T_ER_FONT_NOT_DEFINED", + [45] = "T_ER_FILE_ACCESS", + [46] = "T_ER_WRONG_DNC_STATUS", + [47] = "T_ER_CHANGE_PATH", + [48] = "T_ER_NO_RENAME", + [49] = "T_ER_NO_LOGIN", + [50] = "T_ER_BAD_PARAMETER", + [51] = "T_ER_BAD_NUMBER_FORMAT", + [52] = "T_ER_BAD_MEMADR", + [53] = "T_ER_NO_FREE_SPACE", + [54] = "T_ER_DEL_DIR", + [55] = "T_ER_NO_DIR", + [56] = "T_ER_OPERATING_MODE", + [57] = "T_ER_NO_NEXT_ERROR", + [58] = "T_ER_ACCESS_TIMEOUT", + [59] = "T_ER_NO_WRITE_ACCESS", + [60] = "T_ER_STIB", + [61] = "T_ER_REF_NECESSARY", + [62] = "T_ER_PLC_BUF_FULL", + [63] = "T_ER_NOT_FOUND", + [64] = "T_ER_WRONG_FILE", + [65] = "T_ER_NO_MATCH", + [66] = "T_ER_TOO_MANY_TPTS", + [67] = "T_ER_NOT_ACTIVATED", + [70] = "T_ER_DSP_CHANNEL", + [71] = "T_ER_DSP_PARA", + [72] = "T_ER_OUT_OF_RANGE", + [73] = "T_ER_INVALID_AXIS", + [74] = "T_ER_STREAMING_ACTIVE", + [75] = "T_ER_NO_STREAMING_ACTIVE", + [80] = "T_ER_TO_MANY_OPEN_TCP", + [81] = "T_ER_NO_FREE_HANDLE", + [82] = "T_ER_PLCMEMREMA_CLEAR", + [83] = "T_ER_OSZI_CHSEL", + [90] = "LSV2_BUSY", + [91] = "LSV2_X_BUSY", + [92] = "LSV2_NOCONNECT", + [93] = "LSV2_BAD_BACKUP_FILE", + [94] = "LSV2_RESTORE_NOT_FOUND", + [95] = "LSV2_DLL_NOT_INSTALLED", + [96] = "LSV2_BAD_CONVERT_DLL", + [97] = "LSV2_BAD_BACKUP_LIST", + [99] = "LSV2_UNKNOWN_ERROR", + [100] = "T_BD_NO_NEW_FILE", + [101] = "T_BD_NO_FREE_SPACE", + [102] = "T_BD_FILE_NOT_ALLOWED", + [103] = "T_BD_BAD_FORMAT", + [104] = "T_BD_BAD_BLOCK", + [105] = "T_BD_END_PGM", + [106] = "T_BD_ANZ", + [107] = "T_BD_WIN_NOT_DEFINED", + [108] = "T_BD_WIN_CHANGED", + [110] = "T_BD_DNC_WAIT", + [111] = "T_BD_CANCELLED", + [112] = "T_BD_OSZI_OVERRUN", + [200] = "T_BD_FD", + [255] = "T_USER_ERROR", + }; + + /// The protocol name of a refusal code, or UNKNOWN_<code>. + /// The refusal code byte. + /// The name. + public static string Name(int code) => Names.TryGetValue(code, out var n) ? n : "UNKNOWN_" + code; +} 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/HeidenhainLiveIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs index 4ddff1680..46de2e751 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainLiveIntegrationTests.cs @@ -86,6 +86,49 @@ public sealed class HeidenhainLiveIntegrationTests values[0].StatusCode.ShouldBe(HeidenhainStatus.Good); } + /// + /// Derived String leaves (family classification, pyLSV2 state names, tool axis name) — no extra wire request; + /// each is computed from a reply the session already reads. + /// + [Fact] + public async Task Real_control_derived_name_leaves() + { + Assert.SkipWhen(string.IsNullOrWhiteSpace(Host), "HEIDENHAIN_LSV2_HOST not set — real-controller gate skipped."); + var ct = TestContext.Current.CancellationToken; + var tags = new[] + { + TestTags.Raw("Controller/Model"), TestTags.Raw("Controller/Family"), + TestTags.Raw("Status/ExecutionState"), TestTags.Raw("Status/ExecutionStateName"), + TestTags.Raw("Status/ProgramState"), TestTags.Raw("Status/ProgramStateName"), + TestTags.Raw("Tool/AxisCode"), TestTags.Raw("Tool/AxisName"), + }; + var options = new HeidenhainDriverOptions + { + Devices = [new HeidenhainDeviceOptions { Host = Host!, Port = Port, DeviceName = "dev1" }], + RawTags = tags, + Access = new HeidenhainAccessOptions { InspectPassword = Environment.GetEnvironmentVariable("OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD") is { Length: > 0 } ? "env:OTOPCUA_HEIDENHAIN_INSPECT_PASSWORD" : null }, + }; + await using var driver = new HeidenhainDriver(options, "live"); + await driver.InitializeAsync("{}", ct); + await driver.SubscribeAsync(tags.Select(t => t.RawPath).ToList(), TimeSpan.FromSeconds(1), ct); + var deadline = DateTime.UtcNow.AddSeconds(30); + IReadOnlyList values; + do + { + await Task.Delay(500, ct); + values = await driver.ReadAsync(tags.Select(t => t.RawPath).ToList(), ct); + } while ((values[1].StatusCode != HeidenhainStatus.Good || values[3].StatusCode != HeidenhainStatus.Good) && DateTime.UtcNow < deadline); + + for (var i = 0; i < tags.Length; i++) + TestContext.Current.SendDiagnosticMessage($"{tags[i].RawPath} = {values[i].Value} (0x{values[i].StatusCode:X8})"); + values[1].StatusCode.ShouldBe(HeidenhainStatus.Good); + values[1].Value.ShouldNotBe("Unknown"); + values[3].StatusCode.ShouldBe(HeidenhainStatus.Good); + values[5].StatusCode.ShouldBe(HeidenhainStatus.Good); + // A name leaf never disagrees with its raw leaf's quality. + values[7].StatusCode.ShouldBe(values[6].StatusCode); + } + /// #867: integer tool leaves must reach clients with their declared runtime type, not Double. [Fact] public async Task Real_control_tool_leaves_keep_integer_types() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainPlcDataPyLsv2ParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainPlcDataPyLsv2ParityTests.cs new file mode 100644 index 000000000..5bbfd3ad3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/HeidenhainPlcDataPyLsv2ParityTests.cs @@ -0,0 +1,162 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests; + +/// +/// PLC-memory (R_PR/R_MB) and DATA-path (R_DP) parity with pyLSV2 (MIT). Where pyLSV2 +/// disagrees with a real iTNC 530 capture the capture wins and the test pins the capture (see +/// docs/drivers/Heidenhain.md §"pyLSV2 correspondence"). +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainPlcDataPyLsv2ParityTests +{ + [Theory] + [InlineData(HeidenhainPlcArea.Marker)] + [InlineData(HeidenhainPlcArea.Input)] + [InlineData(HeidenhainPlcArea.Output)] + [InlineData(HeidenhainPlcArea.Counter)] + [InlineData(HeidenhainPlcArea.Timer)] + public void Plc_bit_areas_accept_the_ff_true_byte_real_controls_return_for_the_same_operands(HeidenhainPlcArea area) + { + // R_DP /PLC/memory/I/999 answered type 11 + FF on hermle59; pyLSV2 read_plc_memory unpacks "?" (non-zero = true). + HeidenhainBindingCodec.DecodePlc(area, [0xFF], DriverDataType.Boolean).ShouldBe(true); + HeidenhainBindingCodec.DecodePlc(area, [0x01], DriverDataType.Boolean).ShouldBe(true); + HeidenhainBindingCodec.DecodePlc(area, [0x00], DriverDataType.Boolean).ShouldBe(false); + // Still strict: pyLSV2 would call 0x02 true, but no control has been seen to send it. + Should.Throw(() => HeidenhainBindingCodec.DecodePlc(area, [0x02], DriverDataType.Boolean)); + Should.Throw(() => HeidenhainBindingCodec.DecodePlc(area, [0x00, 0x00], DriverDataType.Boolean)); + } + + [Fact] + public void The_captured_ff_bit_is_the_same_byte_the_r_mb_decoder_now_accepts() + { + var f = Fixtures.Load("plc-count-units.properties"); + var reply = f["hermle59.I.999"]; // S_DP:0000000bff + var value = Convert.FromHexString(reply["S_DP:".Length..]); + Lsv2Data.DataPath(value).ShouldBe(true); + HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Input, value.AsSpan(4), DriverDataType.Boolean).ShouldBe(true); + } + + [Theory] + [InlineData("PLC:INPUT_DWORD:3", HeidenhainPlcArea.InputDword)] + [InlineData("PLC:OUTPUT_DWORD:0", HeidenhainPlcArea.OutputDword)] + public void Input_and_output_dword_areas_parse_as_element_indexes(string address, HeidenhainPlcArea area) + { + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int32"), out var d, out var error) + .ShouldBeTrue(error); + d!.Address.Area.ShouldBe(area); + d.Address.Text.ShouldBe(address); + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int64"), out _, out _).ShouldBeTrue(); + // int32 must not be narrowed into Int16; it is not a Boolean/String area. + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Int16"), out _, out _).ShouldBeFalse(); + HeidenhainTagDefinitionFactory.TryFromTagConfig("p", "d", TestTags.Config(address, "Boolean"), out _, out _).ShouldBeFalse(); + HeidenhainAddress.AreaTokens.ShouldContain(address.Split(':')[1]); + } + + [Fact] + public void Dword_io_views_share_the_word_pools_with_element_index_times_four() + { + var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10)); + var iw = layout.Region(HeidenhainPlcArea.InputWord)!; + var ow = layout.Region(HeidenhainPlcArea.OutputWord)!; + HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 1, layout).ShouldBe(new HeidenhainMemoryRange(iw.Base + 4, 4)); + HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 1, layout).ShouldBe(new HeidenhainMemoryRange(ow.Base + 4, 4)); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 2, layout)); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 2, layout)); + } + + [Theory] + [InlineData("hermle59")] + [InlineData("hermle70")] + [InlineData("hermle29")] + public void Captured_layouts_give_dword_io_views_a_quarter_of_the_byte_pool(string machine) + { + // The input/output word pools are 1000 BYTES on these controls (IW998 is the last valid R_DP word); a + // DWORD view therefore holds 250 elements. pyLSV2 caps INPUT_DWORD at count/4 too. + var f = Fixtures.Load("plc-count-units.properties"); + var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"])); + var iw = layout.Region(HeidenhainPlcArea.InputWord)!; + HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 249, layout).Address.ShouldBe(iw.Base + 996); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputDword, 250, layout)); + var ow = layout.Region(HeidenhainPlcArea.OutputWord)!; + HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 249, layout).Address.ShouldBe(ow.Base + 996); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.OutputDword, 250, layout)); + } + + [Theory] + [InlineData("hermle59")] + [InlineData("hermle70")] + [InlineData("hermle29")] + public void Captured_layouts_contradict_pyLSV2_word_count_units(string machine) + { + // pyLSV2 read_plc_memory: BYTE max = count×2, WORD max = count, INPUT_WORD max = count (count read as words). + // The real controls refuse B10000 / W10000 / IW1000 over R_DP, and each advertised base + count is exactly + // the next advertised base — the counts are bytes. Pin the capture, not pyLSV2. + var f = Fixtures.Load("plc-count-units.properties"); + var layout = Lsv2Data.Layout(Convert.FromHexString(f[machine + ".layout"])); + var words = layout.Region(HeidenhainPlcArea.Word)!; + var iw = layout.Region(HeidenhainPlcArea.InputWord)!; + var ow = layout.Region(HeidenhainPlcArea.OutputWord)!; + var markers = layout.Region(HeidenhainPlcArea.Marker)!; + (words.Base + words.Bytes).ShouldBe(iw.Base); + (iw.Base + iw.Bytes).ShouldBe(ow.Base); + (ow.Base + ow.Bytes).ShouldBe(markers.Base); + f[machine + ".B.10000"].ShouldStartWith("T_ER"); + f[machine + ".W.9998"].ShouldStartWith("S_DP"); + f[machine + ".IW.998"].ShouldStartWith("S_DP"); + f[machine + ".IW.1000"].ShouldStartWith("T_ER"); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Byte, 10000, layout)); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 5000, layout)); + Should.Throw(() => HeidenhainBindingCodec.Range(HeidenhainPlcArea.InputWord, 500, layout)); + } + + [Fact] + public void Dword_io_decodes_signed_int32_little_endian() + { + HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputDword, [0xFE, 0xFF, 0xFF, 0xFF], DriverDataType.Int32).ShouldBe(-2); + HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.OutputDword, [0x01, 0x02, 0x03, 0x04], DriverDataType.Int64).ShouldBe(0x04030201L); + Should.Throw(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputDword, [0, 0], DriverDataType.Int32)); + } + + [Fact] + public void Plc_request_is_element_index_times_width_plus_base_like_pyLSV2() + { + // pyLSV2 read_plc_memory: address = start + first_element × mem_byte_count; count byte = width for one element. + var layout = Lsv2Data.Layout(SimulatedControl.LayoutBytes(10)); + var w = layout.Region(HeidenhainPlcArea.Word)!; + var range = HeidenhainBindingCodec.Range(HeidenhainPlcArea.Word, 3, layout); + range.ShouldBe(new HeidenhainMemoryRange(w.Base + 6, 2)); + var payload = Lsv2ReadOnlyPolicy.MemoryPayload(range.Address, range.Bytes); + BinaryPrimitives.ReadUInt32BigEndian(payload).ShouldBe((uint)(w.Base + 6)); + payload[4].ShouldBe((byte)2); + } + + [Fact] + public void Data_path_request_matches_pyLSV2_formatting() + { + // pyLSV2 read_data_path: four 0x00 bytes + path with / → \ and " → ' + NUL. + var payload = Lsv2ReadOnlyPolicy.DataPathPayload("/TABLE/TOOL/T/1/\"DOC\""); + payload[..4].ShouldBe(new byte[4]); + Encoding.Latin1.GetString(payload, 4, payload.Length - 4).ShouldBe("\\TABLE\\TOOL\\T\\1\\'DOC'\0"); + } + + [Fact] + public void Data_path_value_types_match_pyLSV2_table() + { + Lsv2Data.DataPath([0, 0, 0, 2, 0xFF, 0xFE]).ShouldBe(-2); // !h + Lsv2Data.DataPath([0, 0, 0, 3, 0xFF, 0xFF, 0xFF, 0xFE]).ShouldBe(-2); // !l + var dbl = new byte[12]; + dbl[3] = 5; + BinaryPrimitives.WriteDoubleLittleEndian(dbl.AsSpan(4), 1.25); // +/// Parity with the open-source pyLSV2 (MIT, commit 97a3d691) for every R_RI selector the driver +/// reads. The golden expectations below are what pyLSV2's own decoders (misc.py:decode_axis_location, +/// decode_stack_info, decode_error_message, decode_tool_info, const.py:ExecState / +/// PgmState / LSV2StatusCode) return for the captured iTNC 530 replies — produced by running pyLSV2 +/// over Fixtures/itnc530-inspect.properties, then pinned here. +/// +[Trait("Category", "Unit")] +public sealed class HeidenhainPyLsv2ParityTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static byte[] Payload(string machine, int selector) => + SimReply.FromFixture(Fixtures.Load("itnc530-inspect.properties")[$"{machine}.{selector}"]).Payload!; + + // ---- decoders vs pyLSV2 on the real captures ------------------------------------------------------------- + + [Theory] + [InlineData("hermle49", new[] { 0.08392, 10.4304, 25.72673, 0.0, 270.0, -90.0 }, "TNC:\\PARTPROG\\00588001302.i")] + [InlineData("hermle59", new[] { 0.01119, 10.51793, 25.31073, 0.0, 180.0, -90.0 }, "TNC:\\partprog\\00588001302.i")] + public void Captured_replies_decode_exactly_as_pyLsv2_decodes_them(string machine, double[] positions, string main) + { + var axes = Lsv2Data.Axes(Payload(machine, 22)); + axes.Names.ShouldBe(["X", "Y", "Z", "C", "a", "A"]); + axes.Positions.ShouldBe(positions); + + HeidenhainStateNames.ExecutionState(Lsv2Data.U16(Payload(machine, 23))).ShouldBe("Automatic"); // pyLSV2 AUTOMATIC + HeidenhainStateNames.ProgramState(Lsv2Data.U16(Payload(machine, 26))).ShouldBe("Idle"); // pyLSV2 IDLE + + var program = Lsv2Data.Program(Payload(machine, 24)); + program.Line.ShouldBe(0L); + program.Main.ShouldBe(main); + program.Current.ShouldBe(""); + + var alarm = Lsv2Data.Alarm(Payload(machine, 27)); + (alarm.ErrorClass, alarm.Group, alarm.Number, alarm.Message).ShouldBe((7, 4, -1, "Run test of cutout channels")); + } + + [Fact] + public void Captured_24_byte_tool_reply_decodes_as_pyLsv2_with_axis_Z() + { + var tool = Lsv2Data.Tool(Payload("hermle59", 51)); + (tool.Number, tool.Index, tool.Axis, tool.Length, tool.Radius).ShouldBe((0L, 0, 2, 0.0, 0.0)); + HeidenhainStateNames.ToolAxis(tool.Axis).ShouldBe("Z"); + } + + [Theory] + [InlineData("hermle49", 25, 23, "T_ER_NO_PRIV")] + [InlineData("hermle49", 28, 57, "T_ER_NO_NEXT_ERROR")] + [InlineData("hermle49", 51, 24, "T_ER_WRONG_PARA")] + [InlineData("hermle59", 25, 23, "T_ER_NO_PRIV")] + public void Captured_refusals_carry_the_pyLsv2_status_code_names(string machine, int selector, int code, string name) + { + var refusal = Payload(machine, selector); + refusal[1].ShouldBe((byte)code); + Lsv2RefusalCodes.Name(code).ShouldBe(name); + new Lsv2RefusalException(refusal[0], refusal[1]).Message.ShouldContain(name); + } + + // ---- decoders vs pyLSV2 layouts on synthetic vectors ----------------------------------------------------- + + [Fact] + public void Overrides_are_feed_spindle_rapid_hundredths_like_pyLsv2() + { + var o = new byte[12]; + BinaryPrimitives.WriteUInt32BigEndian(o, 10000); + BinaryPrimitives.WriteUInt32BigEndian(o.AsSpan(4), 15050); + BinaryPrimitives.WriteUInt32BigEndian(o.AsSpan(8), 1); + Lsv2Data.Overrides(o).ShouldBe([100.0, 150.5, 0.01]); + } + + [Fact] + public void Tool_geometry_is_little_endian_after_big_endian_identity_like_pyLsv2() + { + var t = new byte[24]; + BinaryPrimitives.WriteUInt32BigEndian(t, 49); + BinaryPrimitives.WriteUInt16BigEndian(t.AsSpan(4), 3); + BinaryPrimitives.WriteUInt16BigEndian(t.AsSpan(6), 1); + BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(8), 138.1506); + BinaryPrimitives.WriteDoubleLittleEndian(t.AsSpan(16), -0.0101); + var tool = Lsv2Data.Tool(t); + (tool.Number, tool.Index, tool.Axis, tool.Length, tool.Radius).ShouldBe((49L, 3, 1, 138.1506, -0.0101)); + } + + [Fact] + public void Alarm_number_is_signed_int32_like_pyLsv2() + { + // The live 2026-11-11 record 0x8100017B (HMI 702126) is negative as int32 — pyLSV2 unpacks "!l" too. + var a = new byte[8 + 2]; + BinaryPrimitives.WriteUInt16BigEndian(a, 7); + BinaryPrimitives.WriteUInt16BigEndian(a.AsSpan(2), 3); + BinaryPrimitives.WriteUInt32BigEndian(a.AsSpan(4), 0x8100017Bu); + a[8] = (byte)'x'; + Lsv2Data.Alarm(a).Number.ShouldBe(-2130706053); + } + + [Fact] + public void Timers_are_u32be_seconds_published_raw() + { + // per pyLSV2 const.py:ParRRI: u32be, NC uptime +1/s. 0x0001_5180 = 86 400 s = one day; no rescaling. + Lsv2Data.U32([0x00, 0x01, 0x51, 0x80]).ShouldBe(86400L); + Lsv2Data.U32([0xFF, 0xFF, 0xFF, 0xFF]).ShouldBe(4294967295L); + Should.Throw(() => Lsv2Data.U32([0, 0, 1])); + } + + // ---- enum names ------------------------------------------------------------------------------------------ + + [Fact] + public void Name_tables_cover_every_pyLsv2_enum_member_in_order() + { + // per pyLSV2 const.py:ExecState 0..5 and PgmState 0..8 (UPPER_SNAKE there, PascalCase here). + HeidenhainStateNames.ExecutionStates.ShouldBe(["Manual", "Mdi", "PassReferences", "SingleStep", "Automatic", "Undefined"]); + HeidenhainStateNames.ProgramStates.ShouldBe( + ["Started", "Stopped", "Finished", "Cancelled", "Interrupted", "Error", "ErrorCleared", "Idle", "Undefined"]); + HeidenhainStateNames.ToolAxes.ShouldBe(["X", "Y", "Z"]); + } + + [Theory] + [InlineData(6)] + [InlineData(300)] + [InlineData(65535)] + [InlineData(-1)] + public void An_out_of_table_code_is_Unknown_never_an_invented_name(int code) + { + HeidenhainStateNames.ExecutionState(code).ShouldBe(HeidenhainStateNames.Unknown); + HeidenhainStateNames.ToolAxis(code).ShouldBe(HeidenhainStateNames.Unknown); + if (code != 6) HeidenhainStateNames.ProgramState(code).ShouldBe(HeidenhainStateNames.Unknown); + } + + [Fact] + public void Name_leaves_are_additive_String_catalog_entries_on_the_raw_leaves_feature() + { + foreach (var (name, raw) in new[] { ("Status/ExecutionStateName", "Status/ExecutionState"), ("Status/ProgramStateName", "Status/ProgramState"), ("Tool/AxisName", "Tool/AxisCode") }) + { + HeidenhainCatalog.TryGet(name, out var n).ShouldBeTrue(name); + HeidenhainCatalog.TryGet(raw, out var r).ShouldBeTrue(raw); + n!.DataType.ShouldBe(Core.Abstractions.DriverDataType.String); + n.IsArray.ShouldBeFalse(); + n.Feature.ShouldBe(r!.Feature); // one wire read serves both + n.Group.ShouldBe(r.Group); + r.DataType.ShouldBe(Core.Abstractions.DriverDataType.Int32); // the raw leaf is unchanged + } + } + + [Fact] + public async Task Name_leaves_share_the_raw_read_and_leave_the_raw_value_unchanged() + { + var c = new SimulatedControl(); + c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]); + c.Runtime[26] = () => SimReply.Ok("S_RI", [0, 0]); + c.Runtime[51] = () => SimReply.Ok("S_RI", [0, 0, 0, 17, 0, 0, 0, 2]); // 8-byte identity-only reply + string[] addresses = ["Status/ExecutionState", "Status/ExecutionStateName", "Status/ProgramState", "Status/ProgramStateName", + "Tool/AxisCode", "Tool/AxisName", "Tool/Length"]; + await using var sim = Lsv2Simulator.WithControl(c); + var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false); + await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult("")); + await client.OpenAsync(Ct); + await session.InitializeAsync(Ct); + + var v = await session.ReadBatchAsync(addresses.Select(a => TestTags.Def(a)).ToList(), Ct); + + v[0].Value.ShouldBeOfType().ShouldBe(4); + v[1].Value.ShouldBe("Automatic"); + v[2].Value.ShouldBeOfType().ShouldBe(0); + v[3].Value.ShouldBe("Started"); + v[4].Value.ShouldBeOfType().ShouldBe(2); + v[5].Value.ShouldBe("Z"); + v[5].Status.ShouldBe(HeidenhainStatus.Good); // the axis is in the identity part, served even without geometry + v[6].Status.ShouldBe(HeidenhainStatus.BadNotSupported); + sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 23).ShouldBe(1); + sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 26).ShouldBe(1); + sim.Requests.Count(r => r.Command == "R_RI" && r.Selector == 51).ShouldBe(1); + c.Violations.ShouldBeEmpty(); + } + + [Fact] + public async Task A_refused_raw_selector_makes_its_name_leaf_bad_with_the_same_status() + { + var c = new SimulatedControl(); + c.Runtime[26] = () => SimReply.Refuse(23); + await using var sim = Lsv2Simulator.WithControl(c); + var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false); + await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult("")); + await client.OpenAsync(Ct); + await session.InitializeAsync(Ct); + + var v = await session.ReadBatchAsync([TestTags.Def("Status/ProgramState"), TestTags.Def("Status/ProgramStateName")], Ct); + + v.ShouldAllBe(s => s.Status == HeidenhainStatus.BadUserAccessDenied && s.Value == null); + } + + // ---- refusal codes --------------------------------------------------------------------------------------- + + [Theory] + [InlineData(22, HeidenhainStatus.BadNotSupported, true)] // T_ER_UNKNOWN_TELE + [InlineData(24, HeidenhainStatus.BadNotSupported, true)] // T_ER_WRONG_PARA + [InlineData(23, HeidenhainStatus.BadUserAccessDenied, true)] // T_ER_NO_PRIV + [InlineData(49, HeidenhainStatus.BadUserAccessDenied, true)] // T_ER_NO_LOGIN + [InlineData(52, HeidenhainStatus.BadOutOfRange, false)] // T_ER_BAD_MEMADR + [InlineData(72, HeidenhainStatus.BadOutOfRange, false)] // T_ER_OUT_OF_RANGE + [InlineData(58, HeidenhainStatus.BadTimeout, false)] // T_ER_ACCESS_TIMEOUT + [InlineData(56, HeidenhainStatus.BadInvalidState, false)] // T_ER_OPERATING_MODE + [InlineData(57, HeidenhainStatus.BadNoData, false)] // T_ER_NO_NEXT_ERROR (outside the alarm walk) + [InlineData(25, HeidenhainStatus.BadUnexpectedError, false)] // T_ER_BREAK + [InlineData(20, HeidenhainStatus.BadUnexpectedError, false)] // T_ER_BAD_FORMAT + [InlineData(250, HeidenhainStatus.BadUnexpectedError, false)] // not in the table + public void Refusal_codes_map_to_the_most_specific_status_and_only_the_old_stable_set_is_stable(int code, uint status, bool stable) + { + HeidenhainStatus.FromRefusal(code).ShouldBe(status); + HeidenhainStatus.IsStable(HeidenhainStatus.FromRefusal(code)).ShouldBe(stable); + } + + [Fact] + public void New_status_constants_are_the_canonical_opc_ua_numerics() + { + HeidenhainStatus.BadTimeout.ShouldBe(0x800A0000u); + HeidenhainStatus.BadInvalidState.ShouldBe(0x80AF0000u); + HeidenhainStatus.BadNoData.ShouldBe(0x809B0000u); + } + + [Theory] + [InlineData(23, "T_ER_NO_PRIV")] + [InlineData(24, "T_ER_WRONG_PARA")] + [InlineData(52, "T_ER_BAD_MEMADR")] + [InlineData(57, "T_ER_NO_NEXT_ERROR")] + [InlineData(58, "T_ER_ACCESS_TIMEOUT")] + [InlineData(111, "T_BD_CANCELLED")] + [InlineData(1, "UNKNOWN_1")] + public void Refusal_names_follow_the_pyLsv2_status_code_table(int code, string name) => Lsv2RefusalCodes.Name(code).ShouldBe(name); + + [Fact] + public async Task An_access_timeout_refusal_is_BadTimeout_and_retried_not_suppressed() + { + var calls = 0; + var c = new SimulatedControl(); + c.Runtime[23] = () => Interlocked.Increment(ref calls) == 1 ? SimReply.Refuse(58) : SimReply.Ok("S_RI", [0, 4]); + await using var sim = Lsv2Simulator.WithControl(c); + var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false); + await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult("")); + await client.OpenAsync(Ct); + await session.InitializeAsync(Ct); + var tag = TestTags.Def("Status/ExecutionState"); + + (await session.ReadBatchAsync([tag], Ct))[0].Status.ShouldBe(HeidenhainStatus.BadTimeout); + var second = (await session.ReadBatchAsync([tag], Ct))[0]; + + second.Status.ShouldBe(HeidenhainStatus.Good); + second.Value.ShouldBe(4); + calls.ShouldBe(2); + session.CommunicationLost.ShouldBeFalse(); // a refusal is never a transport failure + } + + [Fact] + public async Task Only_57_ends_the_alarm_walk_other_refusals_fail_it() + { + var c = new SimulatedControl(); + var record = new byte[10]; + BinaryPrimitives.WriteUInt16BigEndian(record, 1); + record[8] = (byte)'a'; + c.Runtime[27] = () => SimReply.Ok("S_RI", record); + c.Runtime[28] = () => SimReply.Refuse(58); + await using var sim = Lsv2Simulator.WithControl(c); + var client = new Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1)); + var settings = new HeidenhainSessionSettings(true, true, false, 64, false, false, false); + await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult("")); + await client.OpenAsync(Ct); + await session.InitializeAsync(Ct); + + var count = (await session.ReadBatchAsync([TestTags.Def("Alarms/Count")], Ct))[0]; + + count.Status.ShouldBe(HeidenhainStatus.BadTimeout); // never a truncated list of 1 + c.Runtime[28] = () => SimReply.Refuse(57); + (await session.ReadBatchAsync([TestTags.Def("Alarms/Count")], Ct))[0].Value.ShouldBe(1); + } + + [Fact] + public void The_read_only_allowlist_is_unchanged() + { + // This work adds derived leaves only: no new selector (pyLSV2's 20/21 axis names stay out), request type or role. + Lsv2ReadOnlyPolicy.Selectors.OrderBy(s => s).ShouldBe([22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 51]); + Lsv2ReadOnlyPolicy.Roles.OrderBy(r => r, StringComparer.Ordinal).ShouldBe(["DATA", "DNC", "INSPECT"]); + Lsv2ReadOnlyPolicy.Commands.OrderBy(c => c, StringComparer.Ordinal).ShouldBe(["A_LG", "R_DP", "R_MB", "R_PR", "R_RI", "R_VR"]); + foreach (var s in new[] { 20, 21 }) + { + var payload = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(payload, (ushort)s); + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("R_RI", payload), $"selector {s}"); + } + + Should.Throw(() => Lsv2ReadOnlyPolicy.Check("A_LG", Encoding.Latin1.GetBytes("FILE\0\0"))); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2DataTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2DataTests.cs index 3f445bee1..bacb93e0e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2DataTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests/Lsv2DataTests.cs @@ -153,7 +153,7 @@ public sealed class Lsv2DataTests HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.InputWord, [0xFF, 0xFF], DriverDataType.Int32).ShouldBe(65535); HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Word, [0xFF, 0xFF], DriverDataType.Int16).ShouldBe((short)-1); HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [1], DriverDataType.Boolean).ShouldBe(true); - Should.Throw(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [0xFF], DriverDataType.Boolean)); + Should.Throw(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Marker, [0x02], DriverDataType.Boolean)); Should.Throw(() => HeidenhainBindingCodec.DecodePlc(HeidenhainPlcArea.Dword, new byte[3], DriverDataType.Int64)); Should.Throw(() => HeidenhainBindingCodec.Coerce(1.5, DriverDataType.Int32)); Should.Throw(() => HeidenhainBindingCodec.Coerce(32768, DriverDataType.Int16)); 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