diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index a90a8e34c..4efac5d67 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -301,6 +301,13 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa alarm SET changes (#641) — never on a reconnect of the same set; a replayed `/current` must never re-raise (un-ack). `AlarmEventArgs` gained optional `OpcUaSeverity` (exact Part 9 severity) and `QualityStatusCode`; a quality-only event goes out as `AlarmQualityUpdate` per node and never through the snapshot projection. +- **Heidenhain LSV2 (#803):** read-only at three layers — no `IWritable`, the `HeidenhainTagWritable` deploy gate, and + `Lsv2ReadOnlyPolicy` (payload-checked in `Lsv2Codec.Frame`, the only frame builder; logins + R_VR/R_PR/R_RI/R_MB/R_DP + only). Never widen the allowlist to work around a controller refusal. Init opens nothing (acquisition starts on the + first subscribe/read); reads are cache-only; a refusal/denial/decode error is per-tag quality and does NOT degrade + health — only a down session does (with `LastSuccessfulRead` frozen). Passwords are `secret:`/`env:` refs 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`. - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` (called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md new file mode 100644 index 000000000..a30864d84 --- /dev/null +++ b/docs/drivers/Heidenhain.md @@ -0,0 +1,196 @@ +# Heidenhain LSV2 driver (read-only) + +`DriverType = "Heidenhain"` (`DriverTypeNames.Heidenhain`) · projects `Driver.Heidenhain` (+ `.Contracts`) · +Gitea **#803**. + +A native, **read-only** Heidenhain LSV2 client over TCP (default port **19000**) for iTNC/TNC controls. No +Java/Python runtime and no vendor SDK (RemoTools/DNC) — a managed `System.Net.Sockets` client. It is a port of the +ignitionoee v0.20.3 Java driver (commit `d1dda0a5`, `modules/drivers/gateway/.../driver/heidenhain`, wire reference +`docs/reference/heidenhain-lsv2.md`), whose protocol layouts are in turn derived from pyLSV2 commit `97a3d691`. +**The Java validation evidence (four real iTNC 530 controls, 2026-11-03) is reference evidence, not proof of this +port** — see [Hardware-gated](#hardware-gated--not-yet-proven). + +## Read-only boundary + +Three independent layers, each sufficient on its own for its concern: + +1. **No write capability.** `HeidenhainDriver` does not implement `IWritable`; `DriverInstanceActor` refuses every + OPC write before it reaches the driver (`HeidenhainDriverEndToEndTests`). +2. **No writable nodes.** The deploy gate `HeidenhainTagWritable` refuses a `ReadWrite` access level on any tag + under a Heidenhain driver, so no node is ever advertised writable. A `"writable": true` TagConfig key is + rejected by the tag reader and dropped by the typed editor. +3. **Payload-aware outbound allowlist** (`Lsv2ReadOnlyPolicy`). `Lsv2Codec.Frame` — the only frame builder — checks + command **and** payload immediately before any byte is written. There is no raw-command API (`Lsv2Client`'s + public surface is pinned by a reflection test) and no configuration knob that widens it. + +| Request | Permitted payload | Reply | +|---|---|---| +| `A_LG` | role ∈ {`INSPECT`, `DNC`, `DATA`} NUL, optional NUL-free Latin-1 password NUL | `T_OK` (empty) | +| `R_VR` | empty | `S_VR` | +| `R_PR` | empty | `S_PR` | +| `R_RI` | exactly a uint16be selector ∈ {22–28, 30–34, 51} | `S_RI` | +| `R_MB` | uint32be address + uint8 count, 1 ≤ count ≤ 247, no 32-bit wrap | `S_MB` | +| `R_DP` | four zero bytes + NUL-free path + NUL (≤ 247 bytes total) | `S_DP` | + +Everything else is refused before transmission — every `C_*` (file/PLC/parameter writes, buffer negotiation), +`A_LO`, file reads (`R_FL`, `R_DI`, …), key/scope operations, `PLCDEBUG`/`MONITOR`/`FILE` roles, response +mnemonics sent as requests. `Lsv2ReadOnlyPolicyTests.A_refused_request_never_reaches_the_socket` proves zero bytes +leave the process for a refused request, even through the internal exchange primitive. Socket close ends a +session (no logout command). **Do not broaden the allowlist to work around a controller refusal.** + +## Transport + +`uint32be payloadLength | ASCII command[4] | payload` — legacy buffer mode, 255-byte frame maximum, no `C_CC` +negotiation. One transaction at a time per control (a `SemaphoreSlim` in `Lsv2Client`, and a single acquisition +loop per device). Each transaction has **one monotonic deadline** (`TimeoutMs`) covering write + header + body, so a +trickling peer cannot extend it. EOF, deadline, an oversized (> 247) or unexpected reply, a malformed `T_ER`, or a +`T_OK` with a body **closes the client** — a later request can never consume leftover bytes. A complete +`T_ER`/`T_BD` refusal keeps the stream synchronised. `Close()` never waits for the transaction gate, so it always +interrupts a blocked read. + +## Configuration + +Driver config (`HeidenhainDriverOptions`, AdminUI **HeidenhainDriverForm**) — every field is int/bool/string (no +enums, so the numeric-enum form/factory trap cannot occur): + +| Key | Default | Range / meaning | +|---|---|---| +| `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.InspectPassword` / `DncPassword` / `DataPassword` | — | **References only** (below) | +| `Polling.StatusMs` / `CustomMs` / `SlowMs` | 1000 / 1000 / 10000 | each 100–3600000 | +| `Polling.MaxAlarms` | 64 | 1–256 | +| `Polling.TimersEnabled` | **false** | Experimental selectors 30–34 | +| `Reconnect.InitialDelayMs` / `MaxDelayMs` | 1000 / 30000 | doubling backoff, bounded | +| `ProbeTimeoutSeconds` | 10 | Test Connect, 1–60 | + +Device config (one `/raw` Device per control, AdminUI **HeidenhainDeviceForm**): `Host` (required, no default), +`Port` (19000), optional per-device `InspectPassword`/`DncPassword`/`DataPassword` overrides. A device is validated +only once it carries tags, so an unconfigured default `Device1` does not fault the driver. + +**Credentials.** A password field is blank, `secret:` (the node's encrypted secret store, via +`ISecretResolver`) or `env:`. It is resolved **at each login** and never held in options or session +state. A literal is refused by the parser, both AdminUI forms and the deploy gate `HeidenhainCredentialPersisted` +(the message never echoes the value). Fail-closed: an absent secret / unset variable fails the login rather than +sending the reference text. Plain LSV2 is unencrypted — reach the control over an externally managed secure +network; there is no SSH subsystem. + +## Addresses (TagConfig) + +`{"address": "
", "dataType": "", "isArray": true|false}` — parsed by +`HeidenhainTagDefinitionFactory`, the single authority shared by the driver, the deploy inspector +(`EquipmentTagConfigInspector`) and the AdminUI editor/validator. + +**Fixed namespace** (device-relative; browse-committable from the `/raw` picker without contacting the control): + +| 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 | +| `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 | +| `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 | +| `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 +states: `Unknown`, `Supported`, `Disabled`, `AccessDenied`, `Unsupported`, `InvalidData`, `CommunicationLost` — +evidence about the last read, not a licence assertion. + +**PLC bindings** `PLC::` + `dataType`, read via `R_MB`. `INDEX` is an **element** index (1–10 digits, +≤ uint32), multiplied by the native width and added to the region base the control advertises in `R_PR`. +Numeric `R_PR` counts are **byte capacities** (element count = ⌊bytes / width⌋; BYTE/WORD/DWORD share one pool); +the next populated advertised base is a defensive cap. Bit and STRING counts are entries. + +| Area | Native | Allowed `dataType` | +|---|---|---| +| `MARKER`, `INPUT`, `OUTPUT`, `COUNTER`, `TIMER` | one byte per Boolean (0/1 only) | Boolean | +| `BYTE` / `WORD` / `DWORD` | signed int8 / int16le / int32le | Int16 (BYTE, WORD only), Int32, Int64 | +| `INPUT_WORD`, `OUTPUT_WORD` | uint16le | 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 +offset** — the two address spaces differ (see the wire reference). + +**DATA bindings** `DATA:` + `dataType` ∈ Boolean/Int16/Int32/Int64/Float64/String, read via `R_DP` under the +opt-in DATA role (`/`→`\`, `"`→`'` normalisation; NUL-free Latin-1; ≤ 242 chars). Value types 2/3/5/8/11/16/17 are +decoded strictly; Boolean accepts `00` and `01`/**`FF`** (FF observed on real iTNC 530 I/O). Coercion never +truncates or wraps — otherwise `BadTypeMismatch`. + +At most 256 PLC/DATA bindings per control (excess are skipped and reported on `/hosts`). + +## Quality + +| Condition | Value status / capability | +|---|---| +| Refusal 22/24 | `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) | +| Other refusal | `BadUnexpectedError` / `Unknown` — retried next poll | +| Malformed complete record (length, terminator, NaN, duplicate axis, …) | `BadDecodingError` / `InvalidData` | +| Binding type mismatch | `BadTypeMismatch` | +| Out-of-region binding, alarm overflow | `BadOutOfRange` / `InvalidData` (never a truncated list) | +| Disabled feature (telemetry off, timers off, DATA off) | `BadNotSupported` / `Disabled` | +| Not yet acquired | `BadWaitingForInitialData` | +| Transport lost | **every** remote value of that control `BadNoCommunication` at once; capabilities `CommunicationLost` | + +There is no cross-poll Good cache: each batch reads the wire; within a batch a shared selector (program, +overrides, axes, tool, the whole alarm walk) and aliased custom addresses are read once. + +## Lifecycle, health and comms loss + +- **Construction and `InitializeAsync` open nothing.** Acquisition starts on the first `SubscribeAsync` / + `ReadAsync`, so the `/raw` browse (universal discovery browser, static catalog, `RediscoverPolicy = Once`) never + opens a session to a production control. +- One background loop per control: connect → INSPECT login → `R_VR` (required) → optional DNC/DATA logins → + `R_PR` when PLC bindings exist → poll the Status/Custom/Slow groups. Each established session is a new + **generation**; a batch returning after its session was retired can never overwrite the outage invalidation. +- **Reads are cache-only** (`ReadAsync` never touches the wire); subscriptions push on (value, **status**) change, + so a control returning with identical readings still re-publishes Good. +- Comms loss → all-value invalidation → bounded exponential reconnect (`Reconnect.*`). Unlike the Java base, an + initial connect failure also retries (no manual restart). +- **Health:** `Degraded` only while a control's session is down (connect failing / lost), with + `LastSuccessfulRead` frozen — the conjunction the #527 comms-loss watchdog needs. Per-tag refusals, denials and + decode errors are quality, not health (else the watchdog would reconnect-loop a control that merely denies one + selector). The reference's poll-group "partial data" aggregate is on the `/hosts` diagnostics bag + (`.data` = `Good`/`Partial`/`Lost`, plus `.state` and `.model`). Re-initialise tears down every + session first (#630). + +## Test-Connect probe + +`HeidenhainDriverProbe` (registered on admin and driver nodes, with the node's `ISecretResolver`) opens a real +session to the first device: INSPECT login + `R_VR`. A bare TCP listener does not read as healthy. + +## Tests + +- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests` — in-process **LSV2 simulator** (`Simulator/`): a + scripted raw-socket peer for framing tests and a `SimulatedControl` model that records every request and flags + any non-allowlisted telegram. The Java fixtures' captured iTNC 530 replies (`Fixtures/*.properties`) are replayed + as golden vectors (inspect-only telemetry on two controls; DATA boundary replies on three). +- `Runtime.Tests/Drivers/HeidenhainDriverEndToEndTests` — the real driver inside `DriverInstanceActor`. +- `Configuration.Tests/DraftValidatorHeidenhainTests`, `AdminUI.Tests/Uns/HeidenhainAuthoringTests` + the fleet + parity suites. +- **LiveIntegration** (`--filter Category=LiveIntegration`, skipped unless `HEIDENHAIN_LSV2_HOST` is set; optional + `HEIDENHAIN_LSV2_PORT`, `HEIDENHAIN_LSV2_INSPECT_PASSWORD`): read-only INSPECT identity/selector/layout survey and a + driver end-to-end status poll. Record its output as .NET commissioning evidence. + +## Hardware-gated — not yet proven + +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). +- DNC/DATA permission matrices per control; sustained fleet/load operation. + +## Deliberate exclusions + +Writes of any kind, file upload/download, remote input/keyboard/motion, parameter edits, PLCDEBUG/MONITOR/FILE +roles, scope setup, arbitrary requests, buffer-size negotiation, vendor SDK, serial transport, built-in SSH, +PLC-wide symbol discovery / memory scanning, OEE mapping, and native alarms as Part 9 conditions (alarms are +published as typed arrays). diff --git a/docs/drivers/README.md b/docs/drivers/README.md index 3dd5309fd..18d283159 100644 --- a/docs/drivers/README.md +++ b/docs/drivers/README.md @@ -73,6 +73,7 @@ Three properties worth knowing: | [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHostConnectivityProbe | Gateway/aggregation driver — does not proxy upstream history (no driver has a history capability). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI | | [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) | | [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange` — `IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific | +| [Heidenhain](Heidenhain.md) | `Driver.Heidenhain` (+ `.Contracts`) | A | Native managed LSV2 TCP client (port 19000) — no vendor SDK, no Java/Python | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDriverDiagnostics | **Read-only at three layers**: no `IWritable`, the `HeidenhainTagWritable` deploy gate, and a payload-aware outbound allowlist (logins + read requests only). Cached reads; all-value `BadNoCommunication` on comms loss; passwords are `secret:`/`env:` references only (#803) | | [Sql](Sql.md) | `Driver.Sql` (+ `.Browser`, `.Contracts`, `.Dialect`) | A | ADO.NET behind an `ISqlDialect` seam — `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres` (Npgsql), `Oracle` (ODP.NET), `MySql` (MySqlConnector), `Odbc` (ANSI SQL:2008 profile only) | IDriver, ITagDiscovery, IReadable, IWritable (opt-in), ISubscribable, IHostConnectivityProbe | DB-poll driver (KeyValue / WideRow / named read-only `Query` tags) with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). Writes are off unless `allowWrites` + the tag's `writable`; a tag's `write` block picks Update / Insert / Upsert / stored Procedure (#781; Odbc: Update/Insert). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). | | [Calculation](Calculation.md) | `Driver.Calculation` | A | none — computes from other tags' RawPaths | IDriver, IDependencyConsumer, ISubscribable, IReadable | **Pseudo-driver**, not a wire protocol: signal-level tags computed from other tags via a script, with deploy-time `scriptId`-existence + Tarjan cycle gates. Its probe is parse-only (no backend to reach). `runTimeoutMs` is authorable through `CalculationDriverForm` (the "no typed config form" gap — G-1 — was closed 2026-07-27) | | [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the default historian backend (the other is `Historian.SqlServer`, below). Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) | @@ -100,6 +101,9 @@ Three properties worth knowing: data-plane precedence rules, and a non-trivial known-limitations list need explaining up front: - [MTConnect.md](MTConnect.md) — Agent config, `FullName`==DataItem `id`, `UNAVAILABLE`→`BadNoCommunication`, CONDITION-as-`String`, browse-via-universal-browser, fixture recipe, deferred write-back +- **Heidenhain** (#803) — read-only LSV2 boundary, configuration, address grammar, quality, hardware-gated items: + - [Heidenhain.md](Heidenhain.md) + - **Historian.Gateway** and **Historian.SqlServer** (server-side historian backends, not tag drivers) are documented in the main guide: - [../Historian.md](../Historian.md) — backend selection (`ServerHistorian:Backend`), the SQL Server backend and HistoryUpdate, plus the HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).) diff --git a/docs/drivers/TestConnectProbes.md b/docs/drivers/TestConnectProbes.md index c9797412f..946303481 100644 --- a/docs/drivers/TestConnectProbes.md +++ b/docs/drivers/TestConnectProbes.md @@ -49,6 +49,7 @@ with a human-readable explanation rather than a false-green TCP-open tick. | **TwinCAT** | `AdsClient.Connect` + `ReadStateAsync`. See [degrade semantics](#twincat-degrade) below. | `"ADS state: {state}"` | Deferred — no ADS target | | **FOCAS** | `cnc_allclibhndl3` via a direct `DllImport("fwlib32")` in the probe. See [degrade semantics](#focas-degrade) below. | `"FOCAS handle OK"` | Deferred — no CNC + FWLIB | | **Galaxy** | gRPC unary call to `GalaxyRepository.TestConnection` on the configured mxaccessgw endpoint. See [auth-rejection rule](#galaxy-auth-rejection) below. | `"gateway gRPC OK"` | `http://10.100.0.48:5120` (mxaccessgw) | +| **Heidenhain** | LSV2 `A_LG INSPECT` (password resolved from its `secret:`/`env:` reference) + `R_VR`, through the same read-only allowlist as the driver; a refusal or malformed version record → fail. No logout — the socket close ends the session. | `"LSV2 INSPECT session OK at host:port: NC "` | Deferred — no LSV2 control; simulator-proven | | **MQTT** | One MQTT `CONNECT` (no SUBSCRIBE, no publish) under a `-probe-`-suffixed client id, then disconnect. **A rejected CONNACK is a returned result code, not an exception** — MQTTnet 5 does not throw — so the probe inspects `ResultCode` and reuses the driver's own `DescribeConnackRejection`, making the button and the running driver word a rejection identically. | `"MQTT CONNECT OK"` | `10.100.0.35:8883` (Mosquitto TLS+auth fixture) | **Historian.Wonderware** had a TCP `Hello`→`HelloAck` handshake probe before Phase 5, but the @@ -138,6 +139,7 @@ above. (Live verification on `10.100.0.48:5120` with no key returns | AbLegacy | Deferred | No PLC5/SLC sim; unit-proven + code path identical to AbCip | | TwinCAT | Deferred | No ADS target; unit-proven + degrade guard tested | | FOCAS | Deferred | No CNC + FWLIB on dev host; degrade guard is the CI-observable path | +| Heidenhain | Deferred | No LSV2 control; probe path shared with the simulator-tested driver (#803) | --- 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 50b847df0..bf95afcc5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -104,6 +104,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi { // #630: release prior device state first — a re-init must never leak a session per attempt. await StopDevicesAsync().ConfigureAwait(false); + // The actor re-subscribes after every successful init; a surviving handle would double-publish. + _subscriptions.Clear(); lock (_lifecycleLock) _state = DriverState.Initializing; try {