diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md
index 1e7dc9118..1c2dfd3e1 100644
--- a/.claude/rules/drivers.md
+++ b/.claude/rules/drivers.md
@@ -309,6 +309,16 @@ 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 NAMESPACED refs only —
+ `secret:heidenhain/…` / `env:OTOPCUA_HEIDENHAIN_…`, enforced at parse, resolve, save path, probe and deploy (an
+ unscoped ref let Test Connect exfiltrate any node secret in cleartext A_LG); a bad optional DNC/DATA credential is
+ `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`.
- **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/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index fd177ab16..ee8036a72 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -42,6 +42,8 @@
+
+
@@ -120,6 +122,7 @@
+
diff --git a/docs/drivers/Heidenhain.md b/docs/drivers/Heidenhain.md
new file mode 100644
index 000000000..978088a90
--- /dev/null
+++ b/docs/drivers/Heidenhain.md
@@ -0,0 +1,227 @@
+# 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.** A `ReadWrite` access level is refused at **authoring** — `RawTreeService`
+ create/update/import (so the tag modal, manual entry, CSV import and browse commit), where the modal and the
+ manual-entry grid also fix the access level at Read — and again by the deploy gate `HeidenhainTagWritable`, so no
+ node is ever advertised writable. A `"writable": true` TagConfig key is rejected by the tag reader and dropped by
+ the typed editor. The same paths tie `Tag.DataType` to what the driver publishes (the catalog type for a fixed
+ address — `Float64` ⇒ `Double` — or the binding's declared type): the modals default it from the address, the
+ save path refuses a mismatch, and the deploy gate `HeidenhainTagDataTypeMismatch` catches anything older
+ (one rule, `HeidenhainTagRules`).
+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 — namespaced references only.** A password field is one of:
+
+| Form | Resolved from |
+|---|---|
+| blank | no password |
+| `secret:heidenhain/` (e.g. `secret:heidenhain/line3/inspect`; `[A-Za-z0-9._-]` segments, no `..`) | the node's encrypted secret store (`ISecretResolver`), same slash-scoped naming as `galaxy//apikey` |
+| `env:OTOPCUA_HEIDENHAIN_` (exact upper-case prefix, `NAME` = `[A-Z0-9_]+`) | the process environment |
+
+**Why namespaced:** the reference is resolved on the node (for Test Connect: on the admin node) and sent in
+**cleartext** in `A_LG` to whatever Host the author configured. An unscoped reference would let anyone who can edit
+a driver exfiltrate `env:ConnectionStrings__ConfigDb`, `env:ServerHistorian__ApiKey` or any other secret by pointing
+Host at their own machine. Anything outside the namespace is refused by **every** layer: the parser
+(`HeidenhainOptionsParser`, so the factory and the probe), the resolver **again at resolve time**
+(`HeidenhainCredentials`, fail-closed independently of earlier validation), the AdminUI **save path**
+(`RawTreeService` create/update driver + device refuse the config server-side via `DriverConfigSaveGate`, and the
+driver/device modals disable Save while it is invalid — so a literal never reaches the config database), and the
+deploy gate `HeidenhainCredentialPersisted`. Messages never echo the value.
+
+Resolved **at each login**, never held in options or session state. Fail-closed: an absent secret or unset
+variable fails the login rather than sending the reference text. An unusable **INSPECT** credential fails the
+connect (INSPECT is required). An unusable **optional** DNC/DATA credential — unresolvable, or a password the LSV2
+login payload cannot carry (NUL, non-Latin-1, > 247 bytes) — never fails the session: the login is not sent, the
+reads that need that role read `BadConfigurationError` (capability `ConfigurationError`), and INSPECT telemetry
+continues. 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` |
+| Unusable optional DNC/DATA credential, or a request the read-only policy refuses | `BadConfigurationError` / `ConfigurationError` — that role's reads only; never sent; the session stays up |
+| 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.*`, clamped to 100 ms–10 min).
+ The backoff resets only after a session **published a poll batch** — a control that accepts login + version and
+ then drops is backed off, not re-dialled every `InitialDelayMs`. Unlike the Java base, an initial connect
+ failure also retries (no manual restart). The loop survives any unexpected fault (logged, retired, backed off);
+ JSON-`null` config sections are normalised to defaults by the parser.
+- Subscriptions: the initial push and later publications are ordered under one lock, so a subscriber never
+ receives an older value after a newer one. A re-init disposes every control loop first; a late start of a
+ disposed loop is a no-op.
+- **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`, `OTOPCUA_HEIDENHAIN_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/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
index 88714a5fd..5f9ed4f6e 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -46,6 +46,7 @@ public static class DraftValidator
errors.AddRange(SqlTagDeployChecks.UndeclaredQueryErrors(draft));
errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft));
ValidateMTConnectSingleDevice(draft, errors);
+ errors.AddRange(HeidenhainDeployChecks.Errors(draft));
ValidateNodeAclScopes(draft, errors);
return errors;
}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs
new file mode 100644
index 000000000..4b134c8ba
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs
@@ -0,0 +1,105 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
+
+///
+/// Heidenhain LSV2 deploy gates (lmxopcua#803). All reuse the driver's own Contracts parser, so the gate and
+/// the runtime cannot disagree.
+///
+///
+///
+/// — the driver is read-only (no IWritable), so a ReadWrite
+/// access level would advertise a writable OPC node that can never be written. Refused, so no Heidenhain
+/// node is ever writable.
+/// — a password field that is not blank / secret: /
+/// env: would store a controller password in the config database (replicated to every node). The
+/// message never echoes the value.
+/// — out-of-range driver settings, or an invalid endpoint on a device
+/// that carries tags (the driver would fail to start).
+///
+///
+public static class HeidenhainDeployChecks
+{
+ /// A Heidenhain tag authored ReadWrite.
+ public const string WritableCode = "HeidenhainTagWritable";
+
+ /// A literal password in a Heidenhain DriverConfig/DeviceConfig.
+ public const string CredentialCode = "HeidenhainCredentialPersisted";
+
+ /// A Heidenhain tag whose Tag.DataType disagrees with the type the driver publishes.
+ public const string DataTypeCode = "HeidenhainTagDataTypeMismatch";
+
+ /// Invalid Heidenhain settings or endpoint.
+ public const string ConfigCode = "HeidenhainConfigInvalid";
+
+ /// Runs every Heidenhain gate.
+ /// The draft.
+ /// The errors.
+ public static IReadOnlyList Errors(DraftSnapshot draft)
+ {
+ var errors = new List();
+ foreach (var driver in draft.DriverInstances)
+ {
+ if (!string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.Ordinal)) continue;
+ var devices = draft.Devices
+ .Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))
+ .ToList();
+ var deviceIds = devices.Select(d => d.DeviceId).ToHashSet(StringComparer.Ordinal);
+ var tags = draft.Tags.Where(t => t.DeviceId is not null && deviceIds.Contains(t.DeviceId)).ToList();
+
+ foreach (var tag in tags.Where(t => t.AccessLevel == TagAccessLevel.ReadWrite))
+ {
+ errors.Add(new(WritableCode,
+ $"Tag '{tag.Name}' on Heidenhain driver '{driver.Name}' is ReadWrite. The Heidenhain LSV2 driver is " +
+ "read-only (only logins and read requests are ever sent); set the tag's access level to Read.",
+ tag.TagId));
+ }
+
+ foreach (var tag in tags)
+ {
+ var expected = HeidenhainTagRules.ExpectedTagDataType(tag.TagConfig);
+ if (expected is null || string.Equals(expected, tag.DataType, StringComparison.OrdinalIgnoreCase)) continue;
+ errors.Add(new(DataTypeCode,
+ $"Tag '{tag.Name}' on Heidenhain driver '{driver.Name}' has data type {tag.DataType}, but the driver " +
+ $"publishes {expected} for its address — the node would advertise a type its values never carry.",
+ tag.TagId));
+ }
+
+ HeidenhainDriverOptions options;
+ try
+ {
+ var merged = DriverDeviceConfigMerger.Merge(
+ driver.DriverConfig,
+ devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(),
+ []);
+ options = HeidenhainOptionsParser.Parse(merged);
+ }
+ catch (InvalidOperationException ex)
+ {
+ errors.Add(new(ConfigCode, $"Heidenhain driver '{driver.Name}': {ex.Message}", driver.DriverInstanceId));
+ continue;
+ }
+
+ foreach (var problem in HeidenhainOptionsParser.Validate(options))
+ {
+ var code = problem.Contains("password", StringComparison.OrdinalIgnoreCase) ? CredentialCode : ConfigCode;
+ errors.Add(new(code, $"Heidenhain driver '{driver.Name}': {problem}", driver.DriverInstanceId));
+ }
+
+ foreach (var device in devices)
+ {
+ if (!tags.Any(t => string.Equals(t.DeviceId, device.DeviceId, StringComparison.Ordinal))) continue;
+ var opts = options.Devices.FirstOrDefault(o => string.Equals(o.DeviceName, device.Name, StringComparison.Ordinal));
+ var problems = opts is null ? ["Device has no endpoint."] : HeidenhainOptionsParser.ValidateDevice(opts);
+ foreach (var p in problems)
+ errors.Add(new(ConfigCode, $"Heidenhain device '{device.Name}': {p}", device.DeviceId));
+ }
+ }
+
+ return errors;
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
index e153ea358..3d1bf2deb 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj
@@ -47,6 +47,8 @@
+
+
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
index 5c03e45e8..c50364821 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
@@ -62,6 +62,9 @@ public static class DriverTypeNames
/// opt-in observation write-back via the Agent's AllowPut POST surface.
public const string MTConnect = "MTConnect";
+ /// Heidenhain LSV2 CNC driver — native, read-only (logins + read requests only) over TCP 19000.
+ public const string Heidenhain = "Heidenhain";
+
///
/// Every driver-type string declared above, for callers that need to enumerate
/// the full set (e.g. validation of an authored DriverType).
@@ -80,5 +83,6 @@ public static class DriverTypeNames
Sql,
Mqtt,
MTConnect,
+ Heidenhain,
];
}
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
new file mode 100644
index 000000000..6dcbe5def
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainAddress.cs
@@ -0,0 +1,225 @@
+using System.Text;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// The PLC memory areas a PLC: binding may name (advertised by the control's R_PR layout).
+public enum HeidenhainPlcArea
+{
+ Marker,
+ Input,
+ Output,
+ Counter,
+ Timer,
+ Byte,
+ Word,
+ Dword,
+ String,
+ InputWord,
+ OutputWord,
+}
+
+/// What an address resolves to.
+public enum HeidenhainAddressKind
+{
+ /// A fixed catalog leaf ().
+ Standard,
+
+ /// A PLC memory element read via R_MB.
+ Plc,
+
+ /// A data-path value read via R_DP (DATA role).
+ DataPath,
+}
+
+/// A parsed, validated Heidenhain tag address.
+/// The address family.
+/// The canonical address text.
+/// The fixed leaf, for .
+/// The PLC area, for .
+/// The PLC element index (not an absolute address), for .
+/// The data path as authored, for .
+public sealed record HeidenhainAddress(
+ HeidenhainAddressKind Kind,
+ string Text,
+ HeidenhainCatalogEntry? Catalog = null,
+ HeidenhainPlcArea? Area = null,
+ long Index = 0,
+ string? DataPath = null)
+{
+ /// Legacy-buffer LSV2 payload maximum (255-byte frame − 8-byte header).
+ public const int MaxPayload = 247;
+
+ /// Address prefix of a PLC binding.
+ public const string PlcPrefix = "PLC:";
+
+ /// Address prefix of a DATA-path binding.
+ public const string DataPrefix = "DATA:";
+
+ /// The data types a custom (PLC/DATA) binding may declare — the reference's BOOLEAN/INT16/INT32/INT64/DOUBLE/STRING.
+ public static readonly IReadOnlySet CustomDataTypes = new HashSet
+ {
+ DriverDataType.Boolean, DriverDataType.Int16, DriverDataType.Int32,
+ DriverDataType.Int64, DriverDataType.Float64, DriverDataType.String,
+ };
+
+ private static readonly IReadOnlyDictionary AreaNames =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["MARKER"] = HeidenhainPlcArea.Marker,
+ ["INPUT"] = HeidenhainPlcArea.Input,
+ ["OUTPUT"] = HeidenhainPlcArea.Output,
+ ["COUNTER"] = HeidenhainPlcArea.Counter,
+ ["TIMER"] = HeidenhainPlcArea.Timer,
+ ["BYTE"] = HeidenhainPlcArea.Byte,
+ ["WORD"] = HeidenhainPlcArea.Word,
+ ["DWORD"] = HeidenhainPlcArea.Dword,
+ ["STRING"] = HeidenhainPlcArea.String,
+ ["INPUT_WORD"] = HeidenhainPlcArea.InputWord,
+ ["OUTPUT_WORD"] = HeidenhainPlcArea.OutputWord,
+ };
+
+ /// The wire spelling of a PLC area (MARKER, INPUT_WORD, …).
+ /// The area.
+ /// Its address token.
+ public static string AreaToken(HeidenhainPlcArea area) => AreaNames.First(kv => kv.Value == area).Key;
+
+ /// The PLC area tokens, in declaration order.
+ public static IReadOnlyList AreaTokens { get; } = AreaNames.Keys.ToList();
+
+ /// True for the one-byte-per-entry Boolean bit areas.
+ /// The area.
+ /// Whether it is a bit area.
+ 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).
+ /// 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;
+
+ ///
+ /// Parses an address. Never throws; explains a rejection.
+ ///
+ /// The authored address.
+ /// The parsed address on success.
+ /// Why the address was rejected.
+ /// True when the address is valid.
+ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainAddress? address, out string? error)
+ {
+ address = null;
+ error = null;
+ var t = text?.Trim() ?? "";
+ if (t.Length == 0)
+ {
+ error = "An address is required.";
+ return false;
+ }
+
+ if (t.StartsWith(DataPrefix, StringComparison.Ordinal))
+ {
+ var path = t[DataPrefix.Length..];
+ if (!TryValidateDataPath(path, out error)) return false;
+ address = new HeidenhainAddress(HeidenhainAddressKind.DataPath, t, DataPath: path);
+ return true;
+ }
+
+ if (t.StartsWith(PlcPrefix, StringComparison.Ordinal))
+ {
+ var parts = t.Split(':');
+ if (parts.Length != 3 || !AreaNames.TryGetValue(parts[1], out var area))
+ {
+ error = $"Expected PLC:AREA:INDEX with AREA one of {string.Join(", ", AreaTokens)}.";
+ return false;
+ }
+
+ if (parts[2].Length is < 1 or > 10 || !parts[2].All(char.IsAsciiDigit))
+ {
+ error = "PLC index must be 1–10 decimal digits (an element index, not an absolute address).";
+ return false;
+ }
+
+ var index = long.Parse(parts[2], System.Globalization.CultureInfo.InvariantCulture);
+ if (index > 0xFFFF_FFFFL)
+ {
+ error = "PLC index exceeds uint32.";
+ return false;
+ }
+
+ address = new HeidenhainAddress(HeidenhainAddressKind.Plc, $"{PlcPrefix}{parts[1]}:{index}", Area: area, Index: index);
+ return true;
+ }
+
+ if (HeidenhainCatalog.TryGet(t, out var entry))
+ {
+ address = new HeidenhainAddress(HeidenhainAddressKind.Standard, t, Catalog: entry);
+ return true;
+ }
+
+ error = $"'{t}' is not a fixed Heidenhain leaf (e.g. Status/ExecutionState), a PLC:AREA:INDEX binding or a DATA:/path binding.";
+ return false;
+ }
+
+ ///
+ /// The reference's data-path rules: non-blank, NUL-free Latin-1, and short enough that the
+ /// R_DP payload (4 zero bytes + normalised path + NUL) fits the 247-byte legacy payload.
+ ///
+ /// The data path.
+ /// Why the path was rejected.
+ /// True when the path is sendable.
+ public static bool TryValidateDataPath(string? path, out string? error)
+ {
+ error = null;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ error = "DATA path is blank.";
+ return false;
+ }
+
+ foreach (var c in path)
+ {
+ if (c == '\0' || c > 0xFF)
+ {
+ error = "DATA path must be NUL-free Latin-1 text.";
+ return false;
+ }
+ }
+
+ if (4 + path.Length + 1 > MaxPayload)
+ {
+ error = $"DATA path is too long (max {MaxPayload - 5} characters).";
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// The reference's datatype-vs-area rules for a PLC: binding: Boolean only for bit areas, String
+ /// only for STRING, and integers wide enough for the native range (never narrowing or wrapping).
+ ///
+ /// The PLC area.
+ /// The declared data type.
+ /// Null when acceptable, else the reason.
+ public static string? CheckPlcDataType(HeidenhainPlcArea area, DriverDataType type)
+ {
+ if (IsBitArea(area))
+ return type == DriverDataType.Boolean ? null : $"PLC {AreaToken(area)} is a Boolean area — declare Boolean.";
+ if (area == HeidenhainPlcArea.String)
+ return type == DriverDataType.String ? null : "PLC STRING requires data type String.";
+ if (type is not (DriverDataType.Int16 or DriverDataType.Int32 or DriverDataType.Int64))
+ return $"PLC {AreaToken(area)} is an integer area — declare Int16, Int32 or Int64.";
+ if (type == DriverDataType.Int16 && area is not (HeidenhainPlcArea.Byte or HeidenhainPlcArea.Word))
+ return $"Int16 narrows the native range of PLC {AreaToken(area)} — declare Int32 or Int64.";
+ return null;
+ }
+
+ /// The normalised data path the R_DP request carries (/→\, "→').
+ /// The authored path.
+ /// The normalised path.
+ public static string NormalizeDataPath(string path) => path.Replace('/', '\\').Replace('"', '\'');
+
+ /// Latin-1 encoding used by every LSV2 string.
+ public static Encoding Latin1 => Encoding.Latin1;
+}
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
new file mode 100644
index 000000000..5cef4a304
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainCatalog.cs
@@ -0,0 +1,126 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// The three acquisition cadences a Heidenhain tag can ride.
+public enum HeidenhainPollGroup
+{
+ /// Execution/program state, program, overrides, axes (Polling.StatusMs).
+ Status,
+
+ /// Configured PLC / DATA bindings (Polling.CustomMs).
+ Custom,
+
+ /// Identity, tool, alarms, timers and capability strings (Polling.SlowMs).
+ Slow,
+}
+
+/// One fixed-namespace leaf: its address, OPC data type, rank, cadence and the feature that serves it.
+/// The device-relative address, e.g. Status/ExecutionState.
+/// The OPC UA data type the driver publishes.
+/// True for the variable-length array leaves (axes, alarms).
+/// The poll cadence.
+///
+/// The feature the leaf is acquired through (one wire read per feature per batch); for a
+/// Capabilities/ leaf the feature whose state it reports.
+///
+public sealed record HeidenhainCatalogEntry(
+ string Address, DriverDataType DataType, bool IsArray, HeidenhainPollGroup Group, string Feature)
+{
+ /// True for a local Capabilities/ string, which never causes a wire read.
+ public bool IsCapability => Address.StartsWith(HeidenhainCatalog.CapabilitiesFolder + "/", StringComparison.Ordinal);
+}
+
+///
+/// The stable, general-purpose device-relative namespace (ported from the ignitionoee v0.20.3 reference
+/// HeidenhainTags). Fixed leaves avoid control-dependent path churn; axes use aligned name and
+/// position arrays. There is no OEE mapping here — raw controller enums and units are preserved.
+///
+public static class HeidenhainCatalog
+{
+ /// The capability-string folder.
+ public const string CapabilitiesFolder = "Capabilities";
+
+ /// Feature id for the version / identity reply.
+ public const string IdentityFeature = "Identity";
+
+ /// Feature id reported for the R_PR memory layout used by PLC bindings.
+ public const string PlcLayoutFeature = "PlcLayout";
+
+ /// Feature id reported for the optional DNC login.
+ public const string DncLoginFeature = "DncLogin";
+
+ /// Feature id reported for the opt-in DATA login.
+ public const string DataLoginFeature = "DataLogin";
+
+ /// Experimental timer leaf names, in selector order (30..34).
+ public static readonly IReadOnlyList TimerNames =
+ ["NcUptime", "MachineUptime", "MachineRuntime", "Spindle0Runtime", "Spindle1Runtime"];
+
+ /// Feature-state strings published on Capabilities/ leaves.
+ public static class States
+ {
+ public const string Unknown = "Unknown";
+ public const string Supported = "Supported";
+ public const string Disabled = "Disabled";
+ public const string AccessDenied = "AccessDenied";
+ public const string Unsupported = "Unsupported";
+ public const string InvalidData = "InvalidData";
+ public const string CommunicationLost = "CommunicationLost";
+ public const string ConfigurationError = "ConfigurationError";
+ }
+
+ /// Every fixed leaf, value leaves first then the capability strings.
+ public static IReadOnlyList Entries { get; } = Build();
+
+ private static readonly IReadOnlyDictionary ByAddress =
+ Entries.ToDictionary(e => e.Address, StringComparer.Ordinal);
+
+ /// Looks up a fixed leaf by its exact (case-sensitive) address.
+ /// The address.
+ /// The entry, when found.
+ /// True when is a fixed leaf.
+ public static bool TryGet(string address, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainCatalogEntry? entry)
+ => ByAddress.TryGetValue(address, out entry);
+
+ /// The value features (excluding capability-only features), in catalog order.
+ public static IReadOnlyList ValueFeatures { get; } =
+ Entries.Where(e => !e.IsCapability).Select(e => e.Feature).Distinct().ToList();
+
+ private static List Build()
+ {
+ var list = new List();
+ void Add(string folder, DriverDataType type, bool array, HeidenhainPollGroup group, string feature, params string[] names)
+ {
+ foreach (var n in names) list.Add(new($"{folder}/{n}", type, array, group, feature));
+ }
+
+ Add("Controller", DriverDataType.String, false, HeidenhainPollGroup.Slow, IdentityFeature, "Model", "NcVersion", "PlcVersion", "Options");
+ Add("Status", DriverDataType.Int32, false, HeidenhainPollGroup.Status, "Execution", "ExecutionState");
+ Add("Status", DriverDataType.Int32, false, HeidenhainPollGroup.Status, "ProgramState", "ProgramState");
+ Add("Program", DriverDataType.String, false, HeidenhainPollGroup.Status, "Program", "Main", "Current");
+ Add("Program", DriverDataType.Int64, false, HeidenhainPollGroup.Status, "Program", "Line");
+ Add("Overrides", DriverDataType.Float64, false, HeidenhainPollGroup.Status, "Overrides", "Feed", "Spindle", "Rapid");
+ Add("Axes", DriverDataType.String, true, HeidenhainPollGroup.Status, "Axes", "Names");
+ Add("Axes", DriverDataType.Float64, true, HeidenhainPollGroup.Status, "Axes", "Positions");
+ 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("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");
+ foreach (var t in TimerNames)
+ Add("Timers", DriverDataType.Int64, false, HeidenhainPollGroup.Slow, TimerFeature(t), t);
+
+ var features = list.Select(e => e.Feature).Distinct().ToList();
+ features.AddRange([PlcLayoutFeature, DncLoginFeature, DataLoginFeature]);
+ foreach (var f in features)
+ list.Add(new($"{CapabilitiesFolder}/{f}", DriverDataType.String, false, HeidenhainPollGroup.Slow, f));
+ return list;
+ }
+
+ /// The feature id of an experimental timer leaf.
+ /// One of .
+ /// Timer_<name>.
+ public static string TimerFeature(string timerName) => "Timer_" + timerName;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs
new file mode 100644
index 000000000..157b171dd
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs
@@ -0,0 +1,128 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Heidenhain LSV2 driver configuration (lmxopcua#803). One driver instance serves N controls; each
+/// /raw Device is one controller endpoint (), merged in by
+/// DriverDeviceConfigMerger as the Devices array.
+///
+///
+/// No enum-typed fields, by design. Every value here is an int, a bool or a string, so the
+/// repo-wide "AdminUI form serializes an enum as a number, the factory DTO expects a name" trap cannot
+/// arise for this driver.
+/// Read-only by construction. Nothing in this document can enable a write: the driver does not
+/// implement IWritable, and its outbound LSV2 allowlist admits only logins and read requests.
+///
+public sealed class HeidenhainDriverOptions
+{
+ /// Default LSV2 TCP port.
+ public const int DefaultPort = 19000;
+
+ /// The configured controllers (one per /raw Device).
+ public IReadOnlyList Devices { get; init; } = [];
+
+ /// The authored raw tags this driver serves, injected by the deploy artifact.
+ public IReadOnlyList RawTags { get; init; } = [];
+
+ /// Connect + whole-response transaction deadline, ms (100–60000). Default 5000.
+ public int TimeoutMs { get; init; } = 5000;
+
+ /// Credentials and optional-role access.
+ public HeidenhainAccessOptions Access { get; init; } = new();
+
+ /// Poll cadences and acquisition limits.
+ public HeidenhainPollingOptions Polling { get; init; } = new();
+
+ /// Bounded reconnect backoff after a lost or failed session.
+ public HeidenhainReconnectOptions Reconnect { get; init; } = new();
+
+ /// Timeout for the AdminUI Test Connect probe, seconds (1–60). Default 10.
+ public int ProbeTimeoutSeconds { get; init; } = 10;
+}
+
+/// One Heidenhain control endpoint — a /raw Device's DeviceConfig.
+public sealed class HeidenhainDeviceOptions
+{
+ /// Hostname or IP address of the control. Required; there is no default endpoint.
+ public string Host { get; init; } = "";
+
+ /// LSV2 TCP port (1–65535). Default 19000.
+ public int Port { get; init; } = HeidenhainDriverOptions.DefaultPort;
+
+ /// The /raw Device name (the RawPath device segment) — the tag routing key.
+ public string? DeviceName { get; init; }
+
+ /// Optional per-device INSPECT password reference; overrides .
+ public string? InspectPassword { get; init; }
+
+ /// Optional per-device DNC password reference; overrides .
+ public string? DncPassword { get; init; }
+
+ /// Optional per-device DATA password reference; overrides .
+ public string? DataPassword { get; init; }
+}
+
+///
+/// Access settings. Password fields are references, never literals: blank (no password),
+/// secret:<name> (the node's encrypted secret store) or env:<VARIABLE>. A literal is
+/// refused at parse and at the deploy gate, so a controller password never lands in the config database.
+///
+public sealed class HeidenhainAccessOptions
+{
+ ///
+ /// Enables read-only telemetry (selectors 22–28/30–34/51). Off = no runtime-info requests at all; only
+ /// identity (and configured PLC/DATA bindings) are read. Default on.
+ ///
+ public bool TelemetryEnabled { get; init; } = true;
+
+ ///
+ /// Attempt the optional DNC login when telemetry is enabled. A refused DNC login never blocks reads the
+ /// control permits under INSPECT — each read reply is the authority. Default on.
+ ///
+ public bool DncLoginEnabled { get; init; } = true;
+
+ /// Opt-in DATA login, attempted only when a DATA: binding needs it. Default off.
+ public bool DataEnabled { get; init; }
+
+ /// INSPECT password reference (blank / secret: / env:).
+ public string? InspectPassword { get; init; }
+
+ /// DNC password reference (blank / secret: / env:).
+ public string? DncPassword { get; init; }
+
+ /// DATA password reference (blank / secret: / env:).
+ public string? DataPassword { get; init; }
+}
+
+/// Poll cadences (each 100–3600000 ms) and limits.
+public sealed class HeidenhainPollingOptions
+{
+ /// Status group: execution/program state, program, overrides, axes. Default 1000 ms.
+ public int StatusMs { get; init; } = 1000;
+
+ /// Custom group: PLC and DATA bindings. Default 1000 ms.
+ public int CustomMs { get; init; } = 1000;
+
+ /// Slow group: identity, tool, alarms, timers, capabilities. Default 10000 ms.
+ public int SlowMs { get; init; } = 10000;
+
+ /// Alarm list limit (1–256). Overflow makes every alarm leaf Bad rather than truncating. Default 64.
+ public int MaxAlarms { get; init; } = 64;
+
+ ///
+ /// Experimental timer selectors 30–34 (reference-derived; units/semantics unverified on a real control).
+ /// Default off.
+ ///
+ public bool TimersEnabled { get; init; }
+}
+
+/// Reconnect backoff: doubling from to .
+public sealed class HeidenhainReconnectOptions
+{
+ /// First retry delay, ms (100–600000). Default 1000.
+ public int InitialDelayMs { get; init; } = 1000;
+
+ /// Retry delay ceiling, ms (InitialDelayMs–600000). Default 30000.
+ public int MaxDelayMs { get; init; } = 30000;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs
new file mode 100644
index 000000000..925b4735c
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs
@@ -0,0 +1,271 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// The one parser + validator of the Heidenhain DriverConfig document, shared by the driver factory,
+/// the Test-Connect probe, the deploy gate and the AdminUI form/save path, so none of them can disagree about
+/// what a config means.
+///
+public static class HeidenhainOptionsParser
+{
+ /// Prefix of a secret-store password reference.
+ public const string SecretPrefix = "secret:";
+
+ /// Prefix of an environment-variable password reference.
+ public const string EnvPrefix = "env:";
+
+ ///
+ /// The ONLY secret-store namespace a Heidenhain password may name: secret:heidenhain/<name> (the
+ /// store's slash-separated, driver-scoped naming, as in galaxy/<inst>/apikey). Scoping is the
+ /// exfiltration guard: the reference is resolved on the node and sent in cleartext in A_LG to a Host
+ /// the author chose, so it must never be able to name another subsystem's secret.
+ ///
+ public const string SecretNamespace = "heidenhain/";
+
+ ///
+ /// The ONLY environment variables a Heidenhain password may name: env:OTOPCUA_HEIDENHAIN_<NAME>
+ /// (exact upper-case prefix; NAME is [A-Z0-9_]+) — never ConnectionStrings__*,
+ /// ServerHistorian__ApiKey or any other process variable.
+ ///
+ public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_";
+
+ /// The password fields a Heidenhain DriverConfig/DeviceConfig may carry.
+ public static readonly IReadOnlyList PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"];
+
+ /// The kind of a parsed credential reference.
+ public enum CredentialKind
+ {
+ /// Blank — no password.
+ None,
+
+ /// secret:heidenhain/….
+ Secret,
+
+ /// env:OTOPCUA_HEIDENHAIN_….
+ Environment,
+ }
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ ///
+ /// Deserialises a config document (null sections normalised to defaults). Throws
+ /// on unreadable JSON.
+ ///
+ /// The DriverConfig JSON (blank ⇒ defaults).
+ /// The options.
+ public static HeidenhainDriverOptions Parse(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return new HeidenhainDriverOptions();
+ try
+ {
+ return Normalize(JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidOperationException("Heidenhain driver config deserialised to null."));
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException($"Heidenhain driver config is not valid JSON: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Replaces JSON-null sections ("Access": null, "Polling": null, …) and null list entries
+ /// with their defaults, so nothing downstream can dereference a null section.
+ ///
+ /// The options as deserialised (or constructed).
+ /// An options instance with every section non-null.
+ public static HeidenhainDriverOptions Normalize(HeidenhainDriverOptions o)
+ {
+ ArgumentNullException.ThrowIfNull(o);
+ if (o.Access is not null && o.Polling is not null && o.Reconnect is not null && o.Devices is not null
+ && o.RawTags is not null && o.Devices.All(d => d is not null) && o.RawTags.All(t => t is not null))
+ return o;
+ return new HeidenhainDriverOptions
+ {
+ Devices = (o.Devices ?? []).Where(d => d is not null).ToList(),
+ RawTags = (o.RawTags ?? []).Where(t => t is not null).ToList(),
+ TimeoutMs = o.TimeoutMs,
+ Access = o.Access ?? new HeidenhainAccessOptions(),
+ Polling = o.Polling ?? new HeidenhainPollingOptions(),
+ Reconnect = o.Reconnect ?? new HeidenhainReconnectOptions(),
+ ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
+ };
+ }
+
+ ///
+ /// Validates driver-level ranges and every password reference. Device endpoints are validated per device
+ /// by (a driver may be authored before its devices).
+ ///
+ /// The options.
+ /// Human-readable errors; empty when valid. Never echoes a credential value.
+ public static IReadOnlyList Validate(HeidenhainDriverOptions o)
+ {
+ ArgumentNullException.ThrowIfNull(o);
+ o = Normalize(o);
+ var errors = new List();
+ if (o.TimeoutMs is < 100 or > 60000) errors.Add("TimeoutMs must be 100–60000 ms.");
+ var p = o.Polling;
+ foreach (var (name, v) in new[] { ("StatusMs", p.StatusMs), ("CustomMs", p.CustomMs), ("SlowMs", p.SlowMs) })
+ if (v is < 100 or > 3_600_000) errors.Add($"Polling.{name} must be 100–3600000 ms.");
+ if (p.MaxAlarms is < 1 or > 256) errors.Add("Polling.MaxAlarms must be 1–256.");
+ var r = o.Reconnect;
+ if (r.InitialDelayMs is < 100 or > 600_000) errors.Add("Reconnect.InitialDelayMs must be 100–600000 ms.");
+ if (r.MaxDelayMs < r.InitialDelayMs || r.MaxDelayMs > 600_000)
+ errors.Add("Reconnect.MaxDelayMs must be between InitialDelayMs and 600000 ms.");
+ if (o.ProbeTimeoutSeconds is < 1 or > 60) errors.Add("ProbeTimeoutSeconds must be 1–60.");
+ var a = o.Access;
+ CheckRef(errors, "Access.InspectPassword", a.InspectPassword);
+ CheckRef(errors, "Access.DncPassword", a.DncPassword);
+ CheckRef(errors, "Access.DataPassword", a.DataPassword);
+ foreach (var d in o.Devices)
+ {
+ var label = string.IsNullOrWhiteSpace(d.DeviceName) ? d.Host : d.DeviceName;
+ CheckRef(errors, $"Device '{label}' InspectPassword", d.InspectPassword);
+ CheckRef(errors, $"Device '{label}' DncPassword", d.DncPassword);
+ CheckRef(errors, $"Device '{label}' DataPassword", d.DataPassword);
+ }
+
+ return errors;
+ }
+
+ /// Validates one device endpoint.
+ /// The device.
+ /// Errors; empty when valid.
+ public static IReadOnlyList ValidateDevice(HeidenhainDeviceOptions d)
+ {
+ var errors = new List();
+ var h = d.Host ?? "";
+ if (h.Length == 0 || h.Length > 253 || h.Any(c => char.IsWhiteSpace(c) || c < 32 || c == '/' || c == '\\'))
+ errors.Add("Host must be a hostname or IP address without whitespace or a URL scheme.");
+ if (d.Port is < 1 or > 65535) errors.Add("Port must be 1–65535.");
+ return errors;
+ }
+
+ ///
+ /// True when is an acceptable password field: blank,
+ /// secret:heidenhain/<name> or env:OTOPCUA_HEIDENHAIN_<NAME>.
+ ///
+ /// The field value.
+ /// Whether it is a namespaced reference (or blank).
+ public static bool IsCredentialReference(string? value) => TryParseCredentialReference(value, out _, out _);
+
+ ///
+ /// Parses a credential reference, enforcing the Heidenhain namespace — the single rule the parser, the
+ /// resolver (again at resolve time), the probe, the AdminUI save path and the deploy gate all apply.
+ ///
+ /// The field value.
+ /// The reference kind.
+ /// The full secret name (incl. heidenhain/) or the environment variable name.
+ /// True for blank or a namespaced reference.
+ public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name)
+ {
+ kind = CredentialKind.None;
+ name = "";
+ if (string.IsNullOrEmpty(value)) return true;
+ if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
+ {
+ var secret = value[SecretPrefix.Length..];
+ if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false;
+ var rest = secret[SecretNamespace.Length..];
+ if (rest.Length is 0 or > 200) return false;
+ foreach (var c in rest)
+ if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false;
+ if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false;
+ kind = CredentialKind.Secret;
+ name = secret;
+ return true;
+ }
+
+ if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
+ {
+ var variable = value[EnvPrefix.Length..];
+ if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false;
+ var rest = variable[EnvVariablePrefix.Length..];
+ if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
+ return false;
+ kind = CredentialKind.Environment;
+ name = variable;
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not
+ /// namespaced references — at the top level (a device / merged config), under Access, and under every
+ /// Devices[] entry; keys matched case-insensitively, as the parser binds them. The messages name the
+ /// field, never the value. Unreadable JSON is reported, since it cannot be checked.
+ ///
+ /// The config JSON.
+ /// The problems; empty when every password field is acceptable.
+ public static IReadOnlyList DescribeCredentialProblems(string? json)
+ {
+ var problems = new List();
+ if (string.IsNullOrWhiteSpace(json)) return problems;
+ JsonDocument doc;
+ try
+ {
+ doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
+ }
+ catch (JsonException)
+ {
+ problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked.");
+ return problems;
+ }
+
+ using (doc)
+ {
+ if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems;
+ ScanObject(doc.RootElement, "", problems);
+ foreach (var p in doc.RootElement.EnumerateObject())
+ {
+ if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object)
+ ScanObject(p.Value, "Access.", problems);
+ if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array)
+ {
+ var i = 0;
+ foreach (var d in p.Value.EnumerateArray())
+ {
+ if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems);
+ i++;
+ }
+ }
+ }
+ }
+
+ return problems;
+ }
+
+ /// The one wording of the credential rule (never includes the value).
+ /// The field.
+ /// The message.
+ public static string CredentialRuleMessage(string field) =>
+ $"{field} must be blank, 'secret:{SecretNamespace}' or 'env:{EnvVariablePrefix}' — a literal "
+ + "password, or a reference outside the Heidenhain namespace, is refused.";
+
+ private static void ScanObject(JsonElement obj, string prefix, List problems)
+ {
+ foreach (var p in obj.EnumerateObject())
+ {
+ if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue;
+ var ok = p.Value.ValueKind switch
+ {
+ JsonValueKind.Null => true,
+ JsonValueKind.String => IsCredentialReference(p.Value.GetString()),
+ _ => false,
+ };
+ if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name));
+ }
+ }
+
+ private static void CheckRef(List errors, string field, string? value)
+ {
+ if (!IsCredentialReference(value)) errors.Add(CredentialRuleMessage(field));
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs
new file mode 100644
index 000000000..a3c774701
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagDefinitionFactory.cs
@@ -0,0 +1,230 @@
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// One authored Heidenhain raw tag, resolved.
+/// The RawPath identity (driver wire reference).
+/// The /raw Device the tag lives under.
+/// The parsed address.
+/// The OPC data type the driver publishes.
+/// Whether the value is a 1-D array.
+public sealed record HeidenhainTagDefinition(
+ string RawPath, string DeviceName, HeidenhainAddress Address, DriverDataType DataType, bool IsArray)
+{
+ /// The poll cadence this tag rides.
+ public HeidenhainPollGroup Group => Address.Kind == HeidenhainAddressKind.Standard
+ ? Address.Catalog!.Group
+ : HeidenhainPollGroup.Custom;
+}
+
+///
+/// The single authority on a Heidenhain raw tag's TagConfig: consumed by the driver's table build,
+/// the deploy-time gate () and the AdminUI typed editor's Validate(), so the
+/// editor, the deploy gate and the runtime accept exactly the same blobs.
+///
+///
+/// Shape: {"address": "Status/ExecutionState"},
+/// {"address": "PLC:MARKER:32", "dataType": "Boolean"},
+/// {"address": "DATA:/TABLE/TOOL/T/1/DOC", "dataType": "String"}. Array leaves
+/// (Axes/*, Alarms/Classes|Groups|Numbers|Messages) must carry "isArray": true so the
+/// node's rank matches what the driver publishes; every other address must not.
+/// Never a write flag. There is no writable key: the driver is read-only and the deploy
+/// gate refuses a ReadWrite access level on any Heidenhain tag.
+/// Data types are parsed by NAME only. A digits-only value (a numerically-serialized enum) is
+/// rejected rather than mapped to the ordinal's member.
+///
+public static class HeidenhainTagDefinitionFactory
+{
+ /// Parses one tag. Never throws.
+ /// The tag's RawPath.
+ /// The tag's Device name.
+ /// The TagConfig JSON.
+ /// The resolved definition on success.
+ /// Why the blob was rejected.
+ /// True when the driver will serve this tag.
+ public static bool TryFromTagConfig(
+ string rawPath, string deviceName, string? tagConfig,
+ [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out HeidenhainTagDefinition? definition, out string? error)
+ {
+ definition = null;
+ error = null;
+ if (string.IsNullOrWhiteSpace(tagConfig))
+ {
+ error = "Heidenhain TagConfig is empty.";
+ return false;
+ }
+
+ string? addressText;
+ string? dataTypeText;
+ bool dataTypePresent;
+ bool? isArray;
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ error = "Heidenhain TagConfig must be a JSON object.";
+ return false;
+ }
+
+ addressText = ReadString(root, "address", out var addressBad);
+ if (addressBad)
+ {
+ error = "'address' must be a string.";
+ return false;
+ }
+
+ dataTypeText = ReadString(root, "dataType", out var typeBad);
+ if (typeBad)
+ {
+ error = "'dataType' must be a data type NAME (e.g. \"Int32\"), not a number.";
+ return false;
+ }
+
+ dataTypePresent = !string.IsNullOrWhiteSpace(dataTypeText);
+ isArray = null;
+ if (TryGetProperty(root, "isArray", out var arr))
+ {
+ if (arr.ValueKind is JsonValueKind.True or JsonValueKind.False) isArray = arr.GetBoolean();
+ else if (arr.ValueKind != JsonValueKind.Null)
+ {
+ error = "'isArray' must be true or false.";
+ return false;
+ }
+ }
+
+ if (TryGetProperty(root, "writable", out var w) && w.ValueKind == JsonValueKind.True)
+ {
+ error = "Heidenhain tags are read-only; remove \"writable\": true.";
+ return false;
+ }
+ }
+ catch (JsonException)
+ {
+ error = "Heidenhain TagConfig is not valid JSON.";
+ return false;
+ }
+
+ if (!HeidenhainAddress.TryParse(addressText, out var address, out error)) return false;
+
+ DriverDataType? declared = null;
+ if (dataTypePresent)
+ {
+ if (!TryParseDataTypeName(dataTypeText!, out var parsed))
+ {
+ error = $"dataType '{dataTypeText}' is not a valid DriverDataType name; valid: {string.Join(", ", Enum.GetNames())}.";
+ return false;
+ }
+
+ declared = parsed;
+ }
+
+ DriverDataType type;
+ bool array;
+ if (address.Kind == HeidenhainAddressKind.Standard)
+ {
+ var entry = address.Catalog!;
+ if (declared is { } d && d != entry.DataType)
+ {
+ error = $"'{entry.Address}' publishes {entry.DataType}; dataType {d} does not match.";
+ return false;
+ }
+
+ type = entry.DataType;
+ array = entry.IsArray;
+ if (array && isArray != true)
+ {
+ error = $"'{entry.Address}' is an array leaf — set \"isArray\": true.";
+ return false;
+ }
+ }
+ else
+ {
+ if (declared is not { } d)
+ {
+ error = "PLC and DATA bindings require a dataType (Boolean, Int16, Int32, Int64, Float64 or String).";
+ return false;
+ }
+
+ if (!HeidenhainAddress.CustomDataTypes.Contains(d))
+ {
+ error = d == DriverDataType.Float32
+ ? "Use Float64 for floating bindings."
+ : $"dataType {d} is not supported for bindings; use Boolean, Int16, Int32, Int64, Float64 or String.";
+ return false;
+ }
+
+ if (address.Kind == HeidenhainAddressKind.Plc && HeidenhainAddress.CheckPlcDataType(address.Area!.Value, d) is { } why)
+ {
+ error = why;
+ return false;
+ }
+
+ type = d;
+ array = false;
+ }
+
+ if (!array && isArray == true)
+ {
+ error = $"'{address.Text}' is a scalar — remove \"isArray\": true.";
+ return false;
+ }
+
+ definition = new HeidenhainTagDefinition(rawPath, deviceName, address, type, array);
+ return true;
+ }
+
+ ///
+ /// Deploy-time inspection: empty when accepts the blob (never stricter),
+ /// otherwise one warning explaining the rejection. Never throws.
+ ///
+ /// The TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string? tagConfig)
+ {
+ if (TryFromTagConfig("inspect", "", tagConfig, out _, out var error)) return [];
+ return [$"Heidenhain TagConfig rejected: {error} The driver will skip this tag (BadNodeIdUnknown)."];
+ }
+
+ /// Parses a data type by NAME; digits-only text is refused (the numeric-enum trap).
+ /// The authored text.
+ /// The parsed type.
+ /// True when names a defined member.
+ public static bool TryParseDataTypeName(string raw, out DriverDataType parsed)
+ {
+ parsed = default;
+ var t = raw.Trim();
+ if (t.Length == 0 || t.All(char.IsAsciiDigit) || t.StartsWith('-')) return false;
+ return Enum.TryParse(t, ignoreCase: true, out parsed) && Enum.IsDefined(parsed);
+ }
+
+ private static string? ReadString(JsonElement root, string name, out bool bad)
+ {
+ bad = false;
+ if (!TryGetProperty(root, name, out var el) || el.ValueKind == JsonValueKind.Null) return null;
+ if (el.ValueKind != JsonValueKind.String)
+ {
+ bad = true;
+ return null;
+ }
+
+ return el.GetString();
+ }
+
+ private static bool TryGetProperty(JsonElement root, string name, out JsonElement value)
+ {
+ foreach (var p in root.EnumerateObject())
+ {
+ if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ value = p.Value;
+ return true;
+ }
+ }
+
+ value = default;
+ return false;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs
new file mode 100644
index 000000000..8e454d000
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainTagRules.cs
@@ -0,0 +1,54 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Rules about the Tag ROW (not its TagConfig) for a Heidenhain tag: the one authority shared by every AdminUI
+/// authoring path (RawTagModal, manual entry, CSV import, browse commit — all through RawTreeService)
+/// and the deploy gate, so authoring and deploy can never disagree.
+///
+public static class HeidenhainTagRules
+{
+ ///
+ /// The Tag.DataType (OPC UA built-in type name) a Heidenhain tag must carry for the value the driver
+ /// publishes, or null when the TagConfig does not resolve (the TagConfig gate reports that).
+ ///
+ /// The TagConfig JSON.
+ /// e.g. Int32, Double, String; null when unresolvable.
+ public static string? ExpectedTagDataType(string? tagConfig) =>
+ HeidenhainTagDefinitionFactory.TryFromTagConfig("rule", "", tagConfig, out var def, out _)
+ ? OpcTypeName(def.DataType)
+ : null;
+
+ /// The OPC UA built-in type name (the Tag.DataType vocabulary) for a published data type.
+ /// The driver data type.
+ /// The type name.
+ public static string OpcTypeName(DriverDataType type) => type switch
+ {
+ DriverDataType.Boolean => "Boolean",
+ DriverDataType.Int16 => "Int16",
+ DriverDataType.Int32 => "Int32",
+ DriverDataType.Int64 => "Int64",
+ DriverDataType.Float64 => "Double",
+ DriverDataType.String => "String",
+ _ => type.ToString(),
+ };
+
+ ///
+ /// Why a Heidenhain tag row may not be saved / deployed: a writable access level (the driver is read-only),
+ /// or a Tag.DataType that disagrees with what the driver publishes for its address.
+ ///
+ /// Whether the row's access level is ReadWrite.
+ /// The row's Tag.DataType.
+ /// The row's TagConfig.
+ /// The reason, or null when the row is acceptable.
+ public static string? DescribeRowProblem(bool readWrite, string? tagDataType, string? tagConfig)
+ {
+ if (readWrite)
+ return "Heidenhain tags are read-only — set the access level to Read (the driver never writes to the control).";
+ var expected = ExpectedTagDataType(tagConfig);
+ if (expected is not null && !string.Equals(expected, tagDataType, StringComparison.OrdinalIgnoreCase))
+ return $"Data type {tagDataType} does not match what the driver publishes for this address ({expected}).";
+ return null;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj
new file mode 100644
index 000000000..f0695c1b4
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj
@@ -0,0 +1,15 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ $(NoWarn);CS1591
+
+
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs
new file mode 100644
index 000000000..3c41e41b3
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainBindingCodec.cs
@@ -0,0 +1,116 @@
+using System.Buffers.Binary;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// A binding's value is outside its advertised region / range.
+public sealed class HeidenhainRangeException(string message) : Exception(message);
+
+/// A value cannot be represented in the binding's declared type without truncation or wrap.
+public sealed class HeidenhainTypeMismatchException(string message) : Exception(message);
+
+/// An absolute PLC memory range.
+/// Absolute address.
+/// Byte count.
+public readonly record struct HeidenhainMemoryRange(long Address, int Bytes);
+
+///
+/// The reference's PLC/DATA binding rules (port of HeidenhainBindings): element indexes, byte
+/// capacities, exact lengths, strict little-endian decodes and non-truncating coercion.
+///
+public static class HeidenhainBindingCodec
+{
+ ///
+ /// PLC binding indexes are ELEMENT indexes: multiplied by the native width and added to the advertised
+ /// region base. Capacity is the region's byte count with no second width multiplication.
+ ///
+ /// The area.
+ /// The element index.
+ /// The control's layout.
+ /// The absolute range.
+ public static HeidenhainMemoryRange Range(HeidenhainPlcArea area, long index, Lsv2Layout? layout)
+ {
+ if (layout is null) throw new HeidenhainRangeException("PLC layout unavailable.");
+ var region = layout.Region(area);
+ if (region is null || region.Width is < 1 or > Lsv2Codec.MaxPayload)
+ throw new HeidenhainRangeException("PLC area width unsupported.");
+ var offset = index * region.Width; // index ≤ uint32, width ≤ 247: cannot overflow long
+ if (offset < 0 || offset + region.Width > region.Bytes)
+ throw new HeidenhainRangeException("PLC index outside advertised region.");
+ return new HeidenhainMemoryRange(region.Base + offset, region.Width);
+ }
+
+ /// Decodes PLC bytes for an area and coerces to the declared type.
+ /// The area.
+ /// The bytes read.
+ /// The declared type.
+ /// The value.
+ public static object DecodePlc(HeidenhainPlcArea area, ReadOnlySpan bytes, DriverDataType type)
+ {
+ object value;
+ 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.");
+ value = bytes[0] != 0;
+ }
+ else
+ {
+ value = area switch
+ {
+ HeidenhainPlcArea.Byte => Exact(bytes, 1, b => (int)(sbyte)b[0]),
+ HeidenhainPlcArea.Word => Exact(bytes, 2, b => (int)BinaryPrimitives.ReadInt16LittleEndian(b)),
+ HeidenhainPlcArea.Dword => Exact(bytes, 4, b => BinaryPrimitives.ReadInt32LittleEndian(b)),
+ HeidenhainPlcArea.InputWord or HeidenhainPlcArea.OutputWord =>
+ Exact(bytes, 2, b => (int)BinaryPrimitives.ReadUInt16LittleEndian(b)),
+ HeidenhainPlcArea.String => Lsv2Codec.Text(bytes),
+ _ => throw new Lsv2DecodeException("Unknown PLC area."),
+ };
+ }
+
+ return Coerce(value, type);
+ }
+
+ private static object Exact(ReadOnlySpan bytes, int length, ReadOnlySpanFunc decode)
+ {
+ Lsv2Codec.Length(bytes, length);
+ return decode(bytes);
+ }
+
+ private delegate object ReadOnlySpanFunc(ReadOnlySpan bytes);
+
+ ///
+ /// Strict coercion: same-kind Boolean/String pass; numbers convert only exactly (no fractional truncation,
+ /// no wrap, finite only). Anything else is .
+ ///
+ /// The native value.
+ /// The declared type.
+ /// The coerced value.
+ public static object Coerce(object value, DriverDataType type)
+ {
+ if (type == DriverDataType.String && value is string) return value;
+ if (type == DriverDataType.Boolean && value is bool) return value;
+ if (value is int or long or double)
+ {
+ var d = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
+ if (type == DriverDataType.Float64 && double.IsFinite(d)) return d;
+ if (type is DriverDataType.Int16 or DriverDataType.Int32 or DriverDataType.Int64)
+ {
+ if (!double.IsFinite(d) || d != Math.Round(d) || d < long.MinValue || d >= 9.2233720368547758E18)
+ throw new HeidenhainTypeMismatchException("Non-integral or overflowing binding value.");
+ var l = value switch { int i => i, long x => x, _ => (long)d };
+ switch (type)
+ {
+ case DriverDataType.Int64: return l;
+ case DriverDataType.Int32 when l is >= int.MinValue and <= int.MaxValue: return (int)l;
+ case DriverDataType.Int16 when l is >= short.MinValue and <= short.MaxValue: return (short)l;
+ }
+ }
+ }
+
+ throw new HeidenhainTypeMismatchException("Binding datatype mismatch.");
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs
new file mode 100644
index 000000000..f441dfcb1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs
@@ -0,0 +1,56 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// A password reference that cannot be resolved (or may not be resolved). Never carries the value.
+public sealed class HeidenhainCredentialException(string message) : InvalidOperationException(message);
+
+///
+/// Resolves password references on demand (each login), so plaintext is never held in options or session
+/// state. Fail-closed AT RESOLVE TIME, independently of any earlier validation: only blank,
+/// secret:heidenhain/… and env:OTOPCUA_HEIDENHAIN_… are resolved — anything else (a literal, another
+/// subsystem's secret, ConnectionStrings__*), a secret: with no store, an absent secret or an unset
+/// variable throws . The reference is resolved on this node and sent
+/// in cleartext to the configured control, which is why the namespace is enforced here as well.
+///
+internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func? environment = null)
+{
+ private readonly Func _environment = environment ?? Environment.GetEnvironmentVariable;
+
+ /// The password for a role on a device (device override, then driver-level).
+ public async ValueTask ResolveAsync(HeidenhainDriverOptions options, HeidenhainDeviceOptions device, string role, CancellationToken ct)
+ {
+ var access = options.Access ?? new HeidenhainAccessOptions();
+ var reference = role switch
+ {
+ "INSPECT" => device.InspectPassword ?? access.InspectPassword,
+ "DNC" => device.DncPassword ?? access.DncPassword,
+ "DATA" => device.DataPassword ?? access.DataPassword,
+ _ => throw new HeidenhainCredentialException("Unexpected LSV2 role."),
+ };
+ return await ResolveReferenceAsync(reference, role, ct).ConfigureAwait(false);
+ }
+
+ /// Resolves one reference.
+ public async ValueTask ResolveReferenceAsync(string? reference, string role, CancellationToken ct)
+ {
+ if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name))
+ throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)");
+ switch (kind)
+ {
+ case HeidenhainOptionsParser.CredentialKind.None:
+ return "";
+ case HeidenhainOptionsParser.CredentialKind.Environment:
+ return _environment(name) is { Length: > 0 } value
+ ? value
+ : throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed).");
+ default:
+ if (resolver is null)
+ throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed).");
+ var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
+ return !string.IsNullOrEmpty(resolved)
+ ? resolved
+ : throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed).");
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs
new file mode 100644
index 000000000..cdfe9dbed
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDeviceRuntime.cs
@@ -0,0 +1,402 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// Connection state of one controller.
+public enum HeidenhainConnectionState
+{
+ /// Not started (no subscription/read yet) or between generations.
+ Idle,
+
+ /// Opening a session.
+ Connecting,
+
+ /// A session is established and polling.
+ Connected,
+
+ /// The last connect failed or the session was lost; waiting out the backoff.
+ Backoff,
+
+ /// Stopped (driver shutdown).
+ Stopped,
+}
+
+/// Builds the client for an endpoint (test seam).
+internal delegate Lsv2Client Lsv2ClientFactory(string host, int port, TimeSpan timeout);
+
+///
+/// One controller's acquisition engine: a single background loop owns the session, so every LSV2 transaction
+/// for the device is serialized. The loop connects, polls the three cadence groups, publishes into the cache
+/// and, on transport loss, invalidates EVERY remote value of the device at once and reconnects with bounded
+/// exponential backoff (port of the reference HeidenhainAcquisition + HeidenhainDevice).
+///
+///
+/// Generation guard. Each established session is a new generation. Publication checks the
+/// generation under , so a batch that returns from the wire after its session was
+/// retired can never overwrite the outage invalidation. No lock is held across a network read.
+/// Health vs. per-tag quality. A controller refusal, a denial or a malformed record is per-tag
+/// quality and does not degrade the device: only a failed connect or a lost session does. Otherwise a control
+/// that simply denies one selector would latch Degraded with nothing for the comms-loss watchdog to fix, and
+/// the watchdog would reconnect-loop a production control. The reference's "partial data" aggregate is kept
+/// as a diagnostic ().
+///
+internal sealed class HeidenhainDeviceRuntime : IAsyncDisposable
+{
+ private readonly HeidenhainDeviceOptions _device;
+ private readonly HeidenhainDriverOptions _options;
+ private readonly IReadOnlyList _tags;
+ private readonly IReadOnlyDictionary> _groups;
+ private readonly HeidenhainSession.PasswordSource _passwords;
+ private readonly Action _publish;
+ private readonly Action _stateChanged;
+ private readonly Lsv2ClientFactory _clientFactory;
+ private readonly TimeProvider _time;
+ private readonly ILogger _logger;
+ private readonly object _publication = new();
+ private readonly object _lifecycle = new();
+ private readonly Dictionary _groupGood = new();
+ private CancellationTokenSource? _cts;
+ private Task? _loop;
+ private HeidenhainSession? _session;
+ private long _generation;
+ private HeidenhainConnectionState _state = HeidenhainConnectionState.Idle;
+ private DateTime _stateChangedUtc;
+ private DateTime? _lastSuccessUtc;
+ private string? _lastError;
+ private int _consecutiveFailures;
+ private bool _disposed;
+ private volatile bool _pollProgress;
+
+ /// Consecutive sessions that ended without one published poll batch (test/diagnostic).
+ internal int ConsecutiveFailures => Volatile.Read(ref _consecutiveFailures);
+
+ public HeidenhainDeviceRuntime(
+ HeidenhainDeviceOptions device,
+ HeidenhainDriverOptions options,
+ IReadOnlyList tags,
+ HeidenhainSession.PasswordSource passwords,
+ Action publish,
+ Action stateChanged,
+ Lsv2ClientFactory clientFactory,
+ TimeProvider time,
+ ILogger logger)
+ {
+ _device = device;
+ _options = HeidenhainOptionsParser.Normalize(options);
+ _tags = tags;
+ _passwords = passwords;
+ _publish = publish;
+ _stateChanged = stateChanged;
+ _clientFactory = clientFactory;
+ _time = time;
+ _logger = logger;
+ _stateChangedUtc = time.GetUtcNow().UtcDateTime;
+ _groups = tags.GroupBy(t => t.Group).ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList());
+ }
+
+ /// The host:port key used for host status and per-call host resolution.
+ public string HostName => $"{_device.Host}:{_device.Port}";
+
+ /// The /raw Device name.
+ public string DeviceName => _device.DeviceName ?? "";
+
+ /// The device's authored tags.
+ public IReadOnlyList Tags => _tags;
+
+ /// Current connection state.
+ public HeidenhainConnectionState State { get { lock (_publication) return _state; } }
+
+ /// When last changed.
+ public DateTime StateChangedUtc { get { lock (_publication) return _stateChangedUtc; } }
+
+ /// The last time a batch completed with the session alive (liveness evidence for the watchdog).
+ public DateTime? LastSuccessUtc { get { lock (_publication) return _lastSuccessUtc; } }
+
+ /// The last connect/transport error (never a credential).
+ public string? LastError { get { lock (_publication) return _lastError; } }
+
+ /// The reference's poll-group aggregate for this generation: Good / Partial / Lost / Idle.
+ public string DataHealth
+ {
+ get
+ {
+ lock (_publication)
+ {
+ if (_state != HeidenhainConnectionState.Connected) return _state == HeidenhainConnectionState.Idle ? "Idle" : "Lost";
+ if (_groupGood.Count != _groups.Count || _groupGood.Values.Any(g => !g)) return "Partial";
+ return "Good";
+ }
+ }
+ }
+
+ /// The identity of the current session's control, when connected.
+ public string? ControllerModel { get { lock (_publication) return _session?.Identity?.Model; } }
+
+ /// Starts the acquisition loop (idempotent).
+ public void Start()
+ {
+ lock (_lifecycle)
+ {
+ // A Start racing (or following) DisposeAsync must never revive an orphan loop: the driver may be
+ // iterating a device list it captured just before re-init disposed it.
+ if (_disposed || _loop is not null) return;
+ _cts = new CancellationTokenSource();
+ var token = _cts.Token;
+ _loop = Task.Run(() => RunAsync(token), CancellationToken.None);
+ }
+ }
+
+ private async Task RunAsync(CancellationToken ct)
+ {
+ // Nothing may kill this loop silently: every iteration — connect, poll, retire, backoff — is guarded,
+ // and an unexpected fault becomes a logged retire + backoff, never an unobserved faulted task.
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await RunOnceAsync(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Heidenhain {Host} acquisition loop fault; retrying", HostName);
+ try { RetireAll("Acquisition loop fault: " + ex.GetType().Name); }
+ catch (Exception inner) { _logger.LogError(inner, "Heidenhain {Host} retire failed", HostName); }
+ }
+
+ if (ct.IsCancellationRequested) break;
+ // Backoff resets only when the last session made real progress (published a poll batch): a control
+ // that accepts login + version and then drops must not be re-dialled every InitialDelayMs.
+ if (_pollProgress) _consecutiveFailures = 0;
+ _consecutiveFailures++;
+ try
+ {
+ SetState(HeidenhainConnectionState.Backoff, null);
+ await Task.Delay(BackoffDelay(_consecutiveFailures), _time, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Heidenhain {Host} backoff fault", HostName);
+ try { await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+ }
+ }
+ }
+
+ /// The reconnect delay for the n-th consecutive failure, clamped to sane bounds (never a hot loop).
+ internal TimeSpan BackoffDelay(int failures)
+ {
+ var r = _options.Reconnect ?? new HeidenhainReconnectOptions();
+ var initial = TimeSpan.FromMilliseconds(Math.Clamp(r.InitialDelayMs, 100, 600_000));
+ var max = TimeSpan.FromMilliseconds(Math.Clamp(r.MaxDelayMs, (int)initial.TotalMilliseconds, 600_000));
+ return ConnectionBackoff.ComputeDelay(initial, failures, max);
+ }
+
+ private async Task RunOnceAsync(CancellationToken ct)
+ {
+ {
+ HeidenhainSession? session = null;
+ _pollProgress = false;
+ try
+ {
+ SetState(HeidenhainConnectionState.Connecting, null);
+ var client = _clientFactory(_device.Host, _device.Port, TimeSpan.FromMilliseconds(_options.TimeoutMs));
+ session = new HeidenhainSession(client, HeidenhainSessionSettings.From(_options, _tags), _passwords);
+ await client.OpenAsync(ct).ConfigureAwait(false);
+ await session.InitializeAsync(ct).ConfigureAwait(false);
+ long generation;
+ lock (_publication)
+ {
+ generation = ++_generation;
+ _session = session;
+ _groupGood.Clear();
+ }
+
+ SetState(HeidenhainConnectionState.Connected, null);
+ _logger.LogInformation("Heidenhain {Host} connected ({Model})", HostName, session.Identity?.Model);
+ await PollAsync(session, generation, ct).ConfigureAwait(false);
+ // Poll returns only on session loss.
+ Retire(generation, "LSV2 communication lost");
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // 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})",
+ Lsv2DecodeException => "Malformed identity record",
+ _ => ex.Message,
+ };
+ RetireAll(reason);
+ _logger.LogWarning("Heidenhain {Host} connect failed: {Reason}", HostName, reason);
+ }
+ finally
+ {
+ session?.Close();
+ }
+ }
+ }
+
+ private async Task PollAsync(HeidenhainSession session, long generation, CancellationToken ct)
+ {
+ var intervals = new Dictionary
+ {
+ [HeidenhainPollGroup.Status] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.StatusMs)),
+ [HeidenhainPollGroup.Custom] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.CustomMs)),
+ [HeidenhainPollGroup.Slow] = TimeSpan.FromMilliseconds(Math.Max(100, _options.Polling.SlowMs)),
+ };
+ var start = _time.GetTimestamp();
+ var due = _groups.Keys.ToDictionary(g => g, _ => TimeSpan.Zero);
+ if (due.Count == 0)
+ {
+ // No tags: keep the session as a liveness check only at the slow cadence via identity.
+ await Task.Delay(Timeout.InfiniteTimeSpan, _time, ct).ConfigureAwait(false);
+ return;
+ }
+
+ while (!ct.IsCancellationRequested)
+ {
+ var elapsed = _time.GetElapsedTime(start);
+ foreach (var group in new[] { HeidenhainPollGroup.Status, HeidenhainPollGroup.Custom, HeidenhainPollGroup.Slow })
+ {
+ if (!due.TryGetValue(group, out var at) || at > elapsed) continue;
+ var tags = _groups[group];
+ var samples = await session.ReadBatchAsync(tags, ct).ConfigureAwait(false);
+ if (!Publish(generation, session, group, tags, samples)) return;
+ due[group] = elapsed + intervals[group];
+ }
+
+ var next = due.Values.Min() - _time.GetElapsedTime(start);
+ if (next > TimeSpan.Zero) await Task.Delay(next, _time, ct).ConfigureAwait(false);
+ }
+ }
+
+ /// Publishes one batch iff its generation is still current. False when the session is lost/retired.
+ private bool Publish(long generation, HeidenhainSession session, HeidenhainPollGroup group,
+ IReadOnlyList tags, IReadOnlyList samples)
+ {
+ var now = _time.GetUtcNow().UtcDateTime;
+ lock (_publication)
+ {
+ if (generation != _generation || !ReferenceEquals(_session, session)) return false;
+ if (session.CommunicationLost) return false;
+ var good = true;
+ for (var i = 0; i < tags.Count; i++)
+ {
+ var s = samples[i];
+ _publish(tags[i].RawPath, new DataValueSnapshot(s.Value, s.Status, now, now));
+ if (tags[i].Address.Catalog?.IsCapability == true) continue;
+ // Explicitly disabled optional timers are not partial data.
+ if (s.Status != HeidenhainStatus.Good
+ && !(tags[i].Address.Catalog?.Feature.StartsWith("Timer_", StringComparison.Ordinal) == true && !_options.Polling.TimersEnabled))
+ good = false;
+ }
+
+ _groupGood[group] = good;
+ _lastSuccessUtc = now;
+ _pollProgress = true;
+ return true;
+ }
+ }
+
+ private void Retire(long generation, string reason)
+ {
+ lock (_publication)
+ {
+ if (generation != _generation) return;
+ }
+
+ RetireAll(reason);
+ _logger.LogWarning("Heidenhain {Host}: {Reason}; reconnecting with backoff", HostName, reason);
+ }
+
+ /// Retires the session and invalidates every remote value (capabilities report CommunicationLost).
+ private void RetireAll(string reason)
+ {
+ HeidenhainSession? old;
+ var now = _time.GetUtcNow().UtcDateTime;
+ lock (_publication)
+ {
+ old = _session;
+ _session = null;
+ _generation++;
+ _groupGood.Clear();
+ _lastError = reason;
+ Invalidate(now);
+ }
+
+ old?.Close();
+ SetState(HeidenhainConnectionState.Backoff, reason);
+ }
+
+ private void Invalidate(DateTime now)
+ {
+ foreach (var tag in _tags)
+ {
+ _publish(tag.RawPath, tag.Address.Catalog?.IsCapability == true
+ ? new DataValueSnapshot(HeidenhainCatalog.States.CommunicationLost, HeidenhainStatus.Good, now, now)
+ : new DataValueSnapshot(null, HeidenhainStatus.BadNoCommunication, now, now));
+ }
+ }
+
+ private void SetState(HeidenhainConnectionState next, string? error)
+ {
+ HeidenhainConnectionState old;
+ lock (_publication)
+ {
+ old = _state;
+ if (error is not null) _lastError = error;
+ if (next == HeidenhainConnectionState.Connected) _lastError = null;
+ if (old == next) return;
+ _state = next;
+ _stateChangedUtc = _time.GetUtcNow().UtcDateTime;
+ }
+
+ try { _stateChanged(this, old, next); }
+ catch (Exception ex) { _logger.LogDebug(ex, "Heidenhain state-change handler threw"); }
+ }
+
+ /// Stops the loop: closes the session first (unblocks a read), cancels, joins ≤ 5 s, invalidates.
+ public async ValueTask DisposeAsync()
+ {
+ Task? loop;
+ CancellationTokenSource? cts;
+ lock (_lifecycle)
+ {
+ _disposed = true;
+ loop = _loop;
+ cts = _cts;
+ _loop = null;
+ _cts = null;
+ }
+
+ HeidenhainSession? session;
+ lock (_publication) session = _session;
+ session?.Close();
+ if (cts is not null) await cts.CancelAsync().ConfigureAwait(false);
+ if (loop is not null)
+ {
+ try { await loop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
+ catch (Exception ex) when (ex is TimeoutException or OperationCanceledException) { /* bounded join */ }
+ }
+
+ cts?.Dispose();
+ lock (_publication)
+ {
+ _session = null;
+ _generation++;
+ }
+
+ SetState(HeidenhainConnectionState.Stopped, null);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs
new file mode 100644
index 000000000..65e9ce831
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs
@@ -0,0 +1,423 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Native, READ-ONLY Heidenhain LSV2 driver (lmxopcua#803; protocol/behaviour reference: the ignitionoee
+/// v0.20.3 Java driver). One instance serves N controls, one per /raw Device.
+///
+///
+/// Read-only at three layers. (1) This type does not implement , so the
+/// runtime refuses every OPC write before it reaches the driver; (2) the deploy gate refuses a ReadWrite
+/// access level on any Heidenhain tag, so no node is ever advertised writable; (3) the transport's
+/// admits only logins and read requests, payload-checked, at the last step
+/// before bytes are written.
+/// Cached reads. Acquisition runs on one background loop per control at the configured poll
+/// cadences; and subscriptions are served from the cache, so no network call ever
+/// runs on an OPC UA read thread and subscription sampling cannot exceed the acquisition cadence.
+/// Acquisition starts on the first / , not at
+/// — a throwaway browse instance (the static catalog needs no connection)
+/// therefore never opens a session to a production control.
+/// Comms loss. A lost session publishes BadNoCommunication on every remote value of that
+/// control at once (capability strings read CommunicationLost), marks the driver Degraded with
+/// frozen, and reconnects with bounded exponential backoff;
+/// the first batch of the new generation re-publishes, so recovery is never swallowed by a value-only
+/// dedup (subscriptions dedup on value AND status).
+///
+public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe,
+ IPerCallHostResolver, ISkippedTagReporter, IDriverDiagnostics, IAsyncDisposable
+{
+ /// Most custom (PLC/DATA) bindings one control may carry (reference limit).
+ public const int MaxCustomBindingsPerDevice = 256;
+
+ private readonly string _driverInstanceId;
+ private readonly ILogger _logger;
+ private readonly HeidenhainCredentials _credentials;
+ private readonly Lsv2ClientFactory _clientFactory;
+ private readonly TimeProvider _time;
+ private readonly SkippedTagLedger _skipped = new();
+ private readonly object _lifecycleLock = new();
+ private readonly object _publishLock = new();
+ private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _subscriptions = new();
+ private HeidenhainDriverOptions _options;
+ private IReadOnlyDictionary _byRawPath = new Dictionary();
+ private IReadOnlyList _devices = [];
+ private volatile bool _initialized;
+ private long _nextSubscriptionId;
+ private DriverState _state = DriverState.Unknown;
+ private string? _initError;
+
+ /// Production constructor.
+ /// The parsed options.
+ /// The driver instance id.
+ /// Optional logger.
+ /// Resolver for secret: password references.
+ public HeidenhainDriver(HeidenhainDriverOptions options, string driverInstanceId,
+ ILogger? logger = null, ISecretResolver? secretResolver = null)
+ : this(options, driverInstanceId, logger, new HeidenhainCredentials(secretResolver), null, null)
+ {
+ }
+
+ internal HeidenhainDriver(HeidenhainDriverOptions options, string driverInstanceId, ILogger? logger,
+ HeidenhainCredentials credentials, Lsv2ClientFactory? clientFactory, TimeProvider? time)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ _options = HeidenhainOptionsParser.Normalize(options);
+ _driverInstanceId = driverInstanceId;
+ _logger = logger ?? NullLogger.Instance;
+ _credentials = credentials;
+ _clientFactory = clientFactory ?? ((h, p, t) => new Lsv2Client(h, p, t));
+ _time = time ?? TimeProvider.System;
+ }
+
+ ///
+ public string DriverInstanceId => _driverInstanceId;
+
+ ///
+ public string DriverType => DriverTypeNames.Heidenhain;
+
+ ///
+ public event EventHandler? OnDataChange;
+
+ ///
+ public event EventHandler? OnHostStatusChanged;
+
+ /// Parses + validates a config document (the one authority the factory and the driver share).
+ /// The config JSON.
+ /// The options.
+ public static HeidenhainDriverOptions ParseOptions(string driverConfigJson)
+ {
+ var options = HeidenhainOptionsParser.Parse(driverConfigJson);
+ var errors = HeidenhainOptionsParser.Validate(options);
+ if (errors.Count > 0) throw new InvalidOperationException("Invalid Heidenhain config: " + string.Join("; ", errors));
+ return options;
+ }
+
+ ///
+ public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ // #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
+ {
+ if (!string.IsNullOrWhiteSpace(driverConfigJson) && driverConfigJson.Trim() is not ("{}" or "[]"))
+ _options = ParseOptions(driverConfigJson);
+ BuildTables();
+ lock (_lifecycleLock)
+ {
+ _initError = null;
+ _state = DriverState.Healthy;
+ }
+
+ _initialized = true;
+ }
+ catch (Exception ex)
+ {
+ lock (_lifecycleLock)
+ {
+ _initError = ex.Message;
+ _state = DriverState.Faulted;
+ }
+
+ throw;
+ }
+ }
+
+ private void BuildTables()
+ {
+ var devices = _options.Devices ?? [];
+ _skipped.BeginRebuild(_options.RawTags.Count);
+ var perDevice = new Dictionary>();
+ foreach (var d in devices) perDevice[d] = [];
+
+ foreach (var raw in _options.RawTags)
+ {
+ var device = Route(perDevice.Keys, raw.DeviceName);
+ if (device is null)
+ {
+ _skipped.Skip(raw.RawPath, $"no Heidenhain device named '{raw.DeviceName}'");
+ continue;
+ }
+
+ if (!HeidenhainTagDefinitionFactory.TryFromTagConfig(raw.RawPath, raw.DeviceName, raw.TagConfig, out var def, out var error))
+ {
+ _skipped.Skip(raw.RawPath, error ?? "invalid TagConfig");
+ continue;
+ }
+
+ var list = perDevice[device];
+ if (def.Address.Kind != HeidenhainAddressKind.Standard
+ && list.Count(t => t.Address.Kind != HeidenhainAddressKind.Standard) >= MaxCustomBindingsPerDevice)
+ {
+ _skipped.Skip(raw.RawPath, $"more than {MaxCustomBindingsPerDevice} PLC/DATA bindings on one control");
+ continue;
+ }
+
+ list.Add(def);
+ }
+
+ // A control is validated only when it carries tags: a freshly created /raw Device has an empty
+ // DeviceConfig, and must not fault the whole driver (or the deploy) before it is configured.
+ var deviceErrors = perDevice.Where(kv => kv.Value.Count > 0)
+ .Select(kv => (kv.Key, Errors: HeidenhainOptionsParser.ValidateDevice(kv.Key)))
+ .Where(x => x.Errors.Count > 0)
+ .Select(x => $"Device '{x.Key.DeviceName ?? x.Key.Host}': {string.Join(" ", x.Errors)}")
+ .ToList();
+ if (deviceErrors.Count > 0) throw new InvalidOperationException(string.Join("; ", deviceErrors));
+
+ _skipped.Commit();
+ var runtimes = new List();
+ var byPath = new Dictionary(StringComparer.Ordinal);
+ _cache.Clear();
+ var now = _time.GetUtcNow().UtcDateTime;
+ foreach (var (device, tags) in perDevice)
+ {
+ if (tags.Count == 0) continue; // a control with no authored tags is never contacted
+ var dev = device;
+ var runtime = new HeidenhainDeviceRuntime(
+ dev, _options, tags,
+ (role, ct) => _credentials.ResolveAsync(_options, dev, role, ct),
+ Publish, OnRuntimeState, _clientFactory, _time, _logger);
+ runtimes.Add(runtime);
+ foreach (var t in tags)
+ {
+ byPath[t.RawPath] = runtime;
+ _cache[t.RawPath] = new DataValueSnapshot(null, HeidenhainStatus.BadWaitingForInitialData, null, now);
+ }
+ }
+
+ _devices = runtimes;
+ _byRawPath = byPath;
+ }
+
+ private static HeidenhainDeviceOptions? Route(IEnumerable devices, string deviceName)
+ {
+ var list = devices.ToList();
+ var named = list.FirstOrDefault(d => string.Equals(d.DeviceName, deviceName, StringComparison.Ordinal));
+ if (named is not null) return named;
+ return list.Count == 1 && (string.IsNullOrEmpty(deviceName) || string.IsNullOrEmpty(list[0].DeviceName)) ? list[0] : null;
+ }
+
+ private void EnsureStarted()
+ {
+ if (!_initialized) return;
+ foreach (var d in _devices) d.Start();
+ }
+
+ ///
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
+ InitializeAsync(driverConfigJson, cancellationToken);
+
+ ///
+ public async Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ _initialized = false;
+ await StopDevicesAsync().ConfigureAwait(false);
+ _subscriptions.Clear();
+ lock (_lifecycleLock) _state = DriverState.Unknown;
+ }
+
+ private async Task StopDevicesAsync()
+ {
+ var devices = _devices;
+ _devices = [];
+ _byRawPath = new Dictionary();
+ foreach (var d in devices) await d.DisposeAsync().ConfigureAwait(false);
+ }
+
+ ///
+ public DriverHealth GetHealth()
+ {
+ lock (_lifecycleLock)
+ {
+ if (_state is DriverState.Faulted or DriverState.Initializing or DriverState.Unknown)
+ return new DriverHealth(_state, LastSuccess(), _initError);
+ }
+
+ var devices = _devices;
+ var down = devices.Where(d => d.State is HeidenhainConnectionState.Backoff or HeidenhainConnectionState.Connecting
+ && d.LastError is not null).ToList();
+ var state = down.Count == 0 ? DriverState.Healthy : DriverState.Degraded;
+ var error = down.Count == 0 ? null : string.Join("; ", down.Select(d => $"{d.HostName}: {d.LastError}"));
+ return new DriverHealth(state, LastSuccess(), error);
+ }
+
+ private DateTime? LastSuccess()
+ {
+ DateTime? best = null;
+ foreach (var d in _devices)
+ if (d.LastSuccessUtc is { } t && (best is null || t > best)) best = t;
+ return best;
+ }
+
+ ///
+ public Task> ReadAsync(IReadOnlyList fullReferences, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReferences);
+ EnsureStarted();
+ var now = _time.GetUtcNow().UtcDateTime;
+ var result = new DataValueSnapshot[fullReferences.Count];
+ for (var i = 0; i < fullReferences.Count; i++)
+ result[i] = _cache.TryGetValue(fullReferences[i], out var s)
+ ? s
+ : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
+ return Task.FromResult>(result);
+ }
+
+ ///
+ public Task SubscribeAsync(IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReferences);
+ var sub = new Subscription(Interlocked.Increment(ref _nextSubscriptionId), fullReferences.ToHashSet(StringComparer.Ordinal));
+ // Register + initial push under the publication lock, so no Publish can interleave: either it ran before
+ // (the push carries its value) or it runs after (it reaches this subscription). The subscriber can never
+ // receive an older snapshot after a newer one.
+ lock (_publishLock)
+ {
+ _subscriptions[sub.Id] = sub;
+ var now = _time.GetUtcNow().UtcDateTime;
+ foreach (var r in sub.References)
+ {
+ var snapshot = _cache.TryGetValue(r, out var s) ? s : new DataValueSnapshot(null, HeidenhainStatus.BadNodeIdUnknown, null, now);
+ Raise(sub, r, snapshot);
+ }
+ }
+
+ EnsureStarted();
+ return Task.FromResult(sub);
+ }
+
+ ///
+ public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
+ {
+ if (handle is Subscription s) _subscriptions.TryRemove(s.Id, out _);
+ return Task.CompletedTask;
+ }
+
+ internal void Publish(string rawPath, DataValueSnapshot snapshot)
+ {
+ // Same lock as SubscribeAsync's register + initial push (see there). Lock order is always
+ // runtime publication → _publishLock; nothing takes them the other way round.
+ lock (_publishLock)
+ {
+ var changed = !_cache.TryGetValue(rawPath, out var old)
+ || old.StatusCode != snapshot.StatusCode || !ValuesEqual(old.Value, snapshot.Value);
+ _cache[rawPath] = snapshot;
+ if (!changed) return;
+ foreach (var sub in _subscriptions.Values)
+ if (sub.References.Contains(rawPath)) Raise(sub, rawPath, snapshot);
+ }
+ }
+
+ private void Raise(Subscription sub, string rawPath, DataValueSnapshot snapshot)
+ {
+ try { OnDataChange?.Invoke(this, new DataChangeEventArgs(sub, rawPath, snapshot)); }
+ catch (Exception ex) { _logger.LogWarning(ex, "Heidenhain OnDataChange handler threw for {RawPath}", rawPath); }
+ }
+
+ /// Value equality including element-wise array comparison (the (value, status) dedup).
+ internal static bool ValuesEqual(object? a, object? b)
+ {
+ if (a is null || b is null) return a is null && b is null;
+ if (a is Array x && b is Array y)
+ {
+ if (x.Length != y.Length || x.GetType() != y.GetType()) return false;
+ for (var i = 0; i < x.Length; i++)
+ if (!Equals(x.GetValue(i), y.GetValue(i))) return false;
+ return true;
+ }
+
+ return a.Equals(b);
+ }
+
+ private void OnRuntimeState(HeidenhainDeviceRuntime runtime, HeidenhainConnectionState old, HeidenhainConnectionState next)
+ {
+ var o = ToHostState(old);
+ var n = ToHostState(next);
+ if (o == n) return;
+ try { OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(runtime.HostName, o, n)); }
+ catch (Exception ex) { _logger.LogDebug(ex, "Heidenhain host-status handler threw"); }
+ }
+
+ private static HostState ToHostState(HeidenhainConnectionState s) => s switch
+ {
+ HeidenhainConnectionState.Connected => HostState.Running,
+ HeidenhainConnectionState.Backoff => HostState.Stopped,
+ _ => HostState.Unknown,
+ };
+
+ ///
+ public IReadOnlyList GetHostStatuses() =>
+ _devices.Select(d => new HostConnectivityStatus(d.HostName, ToHostState(d.State), d.StateChangedUtc)).ToList();
+
+ ///
+ public string ResolveHost(string fullReference) =>
+ _byRawPath.TryGetValue(fullReference, out var d) ? d.HostName : _driverInstanceId;
+
+ ///
+ public SkippedTagReport? GetSkippedTags() => _skipped.Snapshot();
+
+ ///
+ public IReadOnlyDictionary GetDiagnostics()
+ {
+ var bag = new Dictionary(StringComparer.Ordinal);
+ foreach (var d in _devices)
+ {
+ bag[$"{d.HostName}.state"] = d.State.ToString();
+ bag[$"{d.HostName}.data"] = d.DataHealth;
+ if (d.ControllerModel is { } m) bag[$"{d.HostName}.model"] = m;
+ }
+
+ return DriverDiagnostics.Bound(bag);
+ }
+
+ ///
+ public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
+
+ ///
+ ///
+ /// The namespace is a fixed catalog (no control-dependent path churn), so the browse picker can offer it
+ /// without contacting the control. PLC/DATA bindings are authored by hand: LSV2 has no proven universal
+ /// symbol model and this driver performs no memory scanning.
+ ///
+ public bool SupportsOnlineDiscovery => true;
+
+ ///
+ public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var folders = new Dictionary(StringComparer.Ordinal);
+ foreach (var e in HeidenhainCatalog.Entries)
+ {
+ var slash = e.Address.IndexOf('/');
+ var folderName = e.Address[..slash];
+ var leaf = e.Address[(slash + 1)..];
+ if (!folders.TryGetValue(folderName, out var folder))
+ folders[folderName] = folder = builder.Folder(folderName, folderName);
+ folder.Variable(leaf, leaf, new DriverAttributeInfo(
+ e.Address, e.DataType, e.IsArray, null, SecurityClassification.ViewOnly, IsHistorized: false));
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
+
+ private sealed class Subscription(long id, HashSet references) : ISubscriptionHandle
+ {
+ public long Id { get; } = id;
+ public HashSet References { get; } = references;
+ public string DiagnosticId => $"heidenhain-sub-{Id}";
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverFactoryExtensions.cs
new file mode 100644
index 000000000..40a47167e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverFactoryExtensions.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Factory registration for . Construction opens nothing (the discovery browser
+/// builds throwaway instances), there is exactly one config parser (),
+/// and the instance is returned as its concrete type so every optional capability is dispatched — and,
+/// because it implements no , no write is.
+///
+public static class HeidenhainDriverFactoryExtensions
+{
+ /// The DriverInstance.DriverType this factory answers to.
+ public const string DriverTypeName = DriverTypeNames.Heidenhain;
+
+ /// Registers the factory.
+ /// The registry.
+ /// Optional logger factory.
+ /// Resolver for secret: password references.
+ public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null, ISecretResolver? secretResolver = null)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
+ }
+
+ /// Creates an instance from a config document.
+ /// The instance id.
+ /// The config JSON.
+ /// Optional logger factory.
+ /// Optional secret resolver.
+ /// The driver.
+ public static HeidenhainDriver CreateInstance(string driverInstanceId, string driverConfigJson,
+ ILoggerFactory? loggerFactory = null, ISecretResolver? secretResolver = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
+ var options = HeidenhainDriver.ParseOptions(driverConfigJson);
+ return new HeidenhainDriver(options, driverInstanceId,
+ logger: loggerFactory?.CreateLogger(), secretResolver: secretResolver);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs
new file mode 100644
index 000000000..44f9c3785
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs
@@ -0,0 +1,79 @@
+using System.Diagnostics;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+///
+/// Test-Connect probe: opens a real LSV2 session to the first configured control, performs the INSPECT
+/// login and reads the version record — the same allowlisted path the driver uses — so a bare TCP listener
+/// never reads as healthy. Nothing else is sent; the socket close ends the session (no logout command).
+///
+///
+/// Registered on admin nodes as well as driver nodes (the Test-Connect singleton is admin-pinned). The
+/// resolver is optional: without one a secret: INSPECT password fails closed with a clear message.
+///
+public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null) : IDriverProbe
+{
+ ///
+ public string DriverType => DriverTypeNames.Heidenhain;
+
+ ///
+ public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
+ {
+ HeidenhainDriverOptions options;
+ try { options = HeidenhainOptionsParser.Parse(configJson); }
+ catch (Exception ex) { return new(false, ex.Message, null); }
+
+ var device = options.Devices.FirstOrDefault();
+ if (device is null || HeidenhainOptionsParser.ValidateDevice(device) is { Count: > 0 })
+ return new(false, "Config has no valid Heidenhain host/port to probe.", null);
+ if (HeidenhainOptionsParser.Validate(options) is { Count: > 0 } errors)
+ return new(false, string.Join("; ", errors), null);
+
+ var budget = timeout > TimeSpan.Zero ? timeout : TimeSpan.FromSeconds(options.ProbeTimeoutSeconds);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(budget);
+ var sw = Stopwatch.StartNew();
+ var target = $"{device.Host}:{device.Port}";
+ try
+ {
+ string password;
+ // Resolved only through the namespaced resolver (fail-closed at resolve time): Test Connect can never be
+ // used to send another subsystem's secret or environment variable to an arbitrary Host.
+ try { password = await new HeidenhainCredentials(secretResolver).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); }
+ catch (HeidenhainCredentialException ex) { return new(false, ex.Message, null); }
+
+ try { _ = Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", password); }
+ catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
+ {
+ return new(false, "The INSPECT password cannot be sent in an LSV2 login (NUL, non-Latin-1 or too long).", null);
+ }
+
+ var perCall = TimeSpan.FromMilliseconds(Math.Min(options.TimeoutMs, budget.TotalMilliseconds));
+ await using var client = new Lsv2Client(device.Host, device.Port, perCall);
+ await client.OpenAsync(cts.Token).ConfigureAwait(false);
+ 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);
+ }
+ catch (OperationCanceledException)
+ {
+ return new(false, $"Probe timed out after {budget.TotalSeconds:F0}s.", null);
+ }
+ catch (Lsv2RefusalException ex)
+ {
+ return new(false, $"Reachable at {target} but the control refused INSPECT/version (code {ex.Code}).", null);
+ }
+ catch (Lsv2DecodeException)
+ {
+ return new(false, $"Reachable at {target} but the version record is malformed — not an LSV2 control?", null);
+ }
+ catch (Lsv2TransportException ex)
+ {
+ return new(false, $"LSV2 session to {target} failed: {ex.Message}", null);
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs
new file mode 100644
index 000000000..46b255b0d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainSession.cs
@@ -0,0 +1,498 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Lsv2;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
+
+/// One acquired leaf: value (null when Bad) + status.
+/// The value, or null.
+/// The OPC UA status code.
+public readonly record struct HeidenhainSample(object? Value, uint Status);
+
+/// What a session needs from the driver configuration.
+internal sealed record HeidenhainSessionSettings(
+ bool TelemetryEnabled,
+ bool DncLoginEnabled,
+ bool DataEnabled,
+ int MaxAlarms,
+ bool TimersEnabled,
+ bool HasPlcBindings,
+ bool HasDataBindings)
+{
+ public static HeidenhainSessionSettings From(HeidenhainDriverOptions o, IReadOnlyCollection tags) => new(
+ o.Access.TelemetryEnabled,
+ o.Access.DncLoginEnabled,
+ o.Access.DataEnabled,
+ o.Polling.MaxAlarms,
+ o.Polling.TimersEnabled,
+ tags.Any(t => t.Address.Kind == HeidenhainAddressKind.Plc),
+ tags.Any(t => t.Address.Kind == HeidenhainAddressKind.DataPath));
+}
+
+///
+/// Generation-local read policy (port of the reference HeidenhainSession): per-batch wire cache,
+/// refused-feature suppression, optional-role login retry and capability strings. There is NO cross-poll Good
+/// cache: every batch reads the wire again. Not thread-safe by design — the owning device loop is the only
+/// caller, which is what serializes controller transactions.
+///
+internal sealed class HeidenhainSession : IAsyncDisposable
+{
+ /// Resolves a role's password on demand; plaintext is never retained in session state.
+ public delegate ValueTask PasswordSource(string role, CancellationToken ct);
+
+ private const uint NotAttempted = uint.MaxValue;
+ private readonly Lsv2Client _client;
+ private readonly HeidenhainSessionSettings _settings;
+ private readonly PasswordSource _passwords;
+ private readonly Dictionary _capabilities = new(StringComparer.Ordinal);
+ private readonly Dictionary _suppressed = new(StringComparer.Ordinal);
+ private Lsv2Layout? _layout;
+ private Lsv2Versions? _identity;
+ private uint _dncStatus = NotAttempted;
+ private uint _dataStatus = NotAttempted;
+ private uint _layoutStatus = NotAttempted;
+ private volatile bool _closed;
+ private volatile bool _lost;
+
+ public HeidenhainSession(Lsv2Client client, HeidenhainSessionSettings settings, PasswordSource passwords)
+ {
+ _client = client;
+ _settings = settings;
+ _passwords = passwords;
+ }
+
+ /// True once a transport failure ended this session.
+ public bool CommunicationLost => _lost || _client.IsClosed;
+
+ /// The identity read at initialize.
+ public Lsv2Versions? Identity => _identity;
+
+ ///
+ /// INSPECT login + version (required; a failure fails the connect), then the optional DNC/DATA logins and
+ /// the PLC layout when configured bindings need them. Optional refusals never fail the session.
+ ///
+ public async Task InitializeAsync(CancellationToken ct)
+ {
+ await _client.LoginAsync("INSPECT", await _passwords("INSPECT", ct).ConfigureAwait(false), ct).ConfigureAwait(false);
+ _identity = Lsv2Data.Versions(await _client.ReadVersionAsync(ct).ConfigureAwait(false));
+ _capabilities[HeidenhainCatalog.IdentityFeature] = HeidenhainCatalog.States.Supported;
+ 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)
+ ? await OptionalLoginAsync("DATA", ct).ConfigureAwait(false)
+ : HeidenhainStatus.BadNotSupported;
+ }
+
+ if (_settings.HasPlcBindings)
+ {
+ try
+ {
+ _layout = Lsv2Data.Layout(await _client.ReadParametersAsync(ct).ConfigureAwait(false));
+ _layoutStatus = HeidenhainStatus.Good;
+ }
+ catch (Lsv2RefusalException e) { _layoutStatus = HeidenhainStatus.FromRefusal(e.Code); }
+ catch (Lsv2DecodeException) { _layoutStatus = HeidenhainStatus.BadDecodingError; }
+ }
+ }
+
+ private async Task OptionalLoginAsync(string role, CancellationToken ct)
+ {
+ try
+ {
+ await LoginWithCredentialAsync(role, ct).ConfigureAwait(false);
+ return HeidenhainStatus.Good;
+ }
+ catch (Lsv2RefusalException e)
+ {
+ return HeidenhainStatus.FromRefusal(e.Code);
+ }
+ catch (QualityException e)
+ {
+ // An unusable optional-role credential degrades only the reads that need that role; INSPECT stays up.
+ return e.Status;
+ }
+ }
+
+ ///
+ /// Resolves the role's password and logs in. A credential problem — an unresolvable reference, or a
+ /// password the policy-checked payload cannot carry (NUL, non-Latin-1, > 247 bytes) — becomes
+ /// (never sent, never a session failure). Refusals and
+ /// transport failures propagate unchanged.
+ ///
+ private async Task LoginWithCredentialAsync(string role, CancellationToken ct)
+ {
+ string password;
+ try { password = await _passwords(role, ct).ConfigureAwait(false); }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ throw new QualityException(HeidenhainStatus.BadConfigurationError, HeidenhainCatalog.States.ConfigurationError);
+ }
+
+ try { _ = Lsv2ReadOnlyPolicy.LoginPayload(role, password); }
+ catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
+ {
+ throw new QualityException(HeidenhainStatus.BadConfigurationError, HeidenhainCatalog.States.ConfigurationError);
+ }
+
+ await _client.LoginAsync(role, password, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Reads one batch. Shared-selector replies are fetched once per batch; aliases of a custom address share
+ /// one wire read. On transport loss every remote leaf of the batch is BadNoCommunication and
+ /// becomes true.
+ ///
+ public async Task> ReadBatchAsync(IReadOnlyList tags, CancellationToken ct)
+ {
+ var outcomes = new Dictionary(StringComparer.Ordinal);
+ var wire = new Dictionary(StringComparer.Ordinal);
+ var result = new HeidenhainSample[tags.Count];
+ for (var i = 0; i < tags.Count; i++)
+ {
+ var tag = tags[i];
+ if (tag.Address.Catalog?.IsCapability == true) continue;
+ var key = OutcomeKey(tag);
+ if (!outcomes.TryGetValue(key, out var o))
+ {
+ o = await AcquireAsync(tag, wire, ct).ConfigureAwait(false);
+ outcomes[key] = o;
+ }
+
+ var status = o.Status;
+ object? value = null;
+ if (status == HeidenhainStatus.Good)
+ {
+ try
+ {
+ value = Leaf(tag, o.Value);
+ if (value is null) status = HeidenhainStatus.BadNotSupported;
+ }
+ catch (HeidenhainTypeMismatchException) { status = HeidenhainStatus.BadTypeMismatch; }
+ }
+
+ result[i] = new HeidenhainSample(value, status);
+ }
+
+ var dead = _closed || CommunicationLost;
+ for (var i = 0; i < tags.Count; i++)
+ {
+ var tag = tags[i];
+ if (tag.Address.Catalog?.IsCapability == true)
+ result[i] = new HeidenhainSample(dead ? HeidenhainCatalog.States.CommunicationLost : Capability(tag.Address.Catalog.Feature), HeidenhainStatus.Good);
+ else if (dead)
+ result[i] = new HeidenhainSample(null, HeidenhainStatus.BadNoCommunication);
+ }
+
+ return result;
+ }
+
+ private static string OutcomeKey(HeidenhainTagDefinition tag) => tag.Address.Kind == HeidenhainAddressKind.Standard
+ ? tag.Address.Catalog!.Feature
+ : "Custom|" + tag.Address.Text + "|" + tag.DataType;
+
+ /// The current capability string for a feature.
+ public string Capability(string feature)
+ {
+ switch (feature)
+ {
+ case HeidenhainCatalog.DncLoginFeature:
+ if (!_settings.TelemetryEnabled || !_settings.DncLoginEnabled) return HeidenhainCatalog.States.Disabled;
+ return LoginState(_dncStatus);
+ case HeidenhainCatalog.DataLoginFeature:
+ if (!_settings.DataEnabled || !_settings.HasDataBindings) return HeidenhainCatalog.States.Disabled;
+ return LoginState(_dataStatus);
+ case HeidenhainCatalog.PlcLayoutFeature:
+ if (!_settings.HasPlcBindings) return HeidenhainCatalog.States.Disabled;
+ return LoginState(_layoutStatus);
+ }
+
+ if (_capabilities.TryGetValue(feature, out var s)) return s;
+ if (feature.StartsWith("Timer_", StringComparison.Ordinal) && !_settings.TimersEnabled) return HeidenhainCatalog.States.Disabled;
+ if (feature != HeidenhainCatalog.IdentityFeature && !_settings.TelemetryEnabled) return HeidenhainCatalog.States.Disabled;
+ return HeidenhainCatalog.States.Unknown;
+ }
+
+ private static string LoginState(uint status) => status switch
+ {
+ NotAttempted => HeidenhainCatalog.States.Unknown,
+ HeidenhainStatus.Good => HeidenhainCatalog.States.Supported,
+ _ => HeidenhainStatus.State(status),
+ };
+
+ private async Task AcquireAsync(HeidenhainTagDefinition tag, Dictionary wire, CancellationToken ct)
+ {
+ if (_closed || CommunicationLost) return new Outcome(null, HeidenhainStatus.BadNoCommunication);
+ var isCustom = tag.Address.Kind != HeidenhainAddressKind.Standard;
+ var feature = isCustom ? tag.Address.Text : tag.Address.Catalog!.Feature;
+ var suppressKey = isCustom ? "Custom|" + tag.Address.Text : feature;
+ if (_suppressed.TryGetValue(suppressKey, out var suppressedStatus)) return new Outcome(null, suppressedStatus);
+ try
+ {
+ object value;
+ if (isCustom)
+ {
+ value = await CustomAsync(tag, wire, ct).ConfigureAwait(false);
+ }
+ else if (feature == HeidenhainCatalog.IdentityFeature)
+ {
+ value = Lsv2Data.Versions(await WireAsync(wire, "Identity", c => _client.ReadVersionAsync(c), ct).ConfigureAwait(false));
+ }
+ else
+ {
+ if (feature.StartsWith("Timer_", StringComparison.Ordinal) && !_settings.TimersEnabled)
+ throw new QualityException(HeidenhainStatus.BadNotSupported, HeidenhainCatalog.States.Disabled);
+ await PrepareTelemetryAsync(wire, ct).ConfigureAwait(false);
+ value = feature switch
+ {
+ "Execution" => Lsv2Data.U16(await _client.ReadRuntimeAsync(23, ct).ConfigureAwait(false)),
+ "ProgramState" => Lsv2Data.U16(await _client.ReadRuntimeAsync(26, ct).ConfigureAwait(false)),
+ "Program" => Lsv2Data.Program(await _client.ReadRuntimeAsync(24, ct).ConfigureAwait(false)),
+ "Overrides" => Lsv2Data.Overrides(await _client.ReadRuntimeAsync(25, ct).ConfigureAwait(false)),
+ "Axes" => Lsv2Data.Axes(await _client.ReadRuntimeAsync(22, ct).ConfigureAwait(false)),
+ "Tool" => Lsv2Data.Tool(await _client.ReadRuntimeAsync(51, ct).ConfigureAwait(false)),
+ "Alarms" => await AlarmsAsync(ct).ConfigureAwait(false),
+ _ => await TimerAsync(feature, ct).ConfigureAwait(false),
+ };
+ }
+
+ if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.Supported;
+ return new Outcome(value, HeidenhainStatus.Good);
+ }
+ catch (Lsv2RefusalException e)
+ {
+ var s = HeidenhainStatus.FromRefusal(e.Code);
+ // A control requiring DNC may deny a selector while its optional role login is temporarily
+ // unavailable. Keep retrying that feature so a later login success can recover it; stable
+ // per-selector denials still suppress until reconnect.
+ var provisionalDncDenial = s == HeidenhainStatus.BadUserAccessDenied && !isCustom
+ && feature != HeidenhainCatalog.IdentityFeature
+ && _dncStatus != HeidenhainStatus.Good && _dncStatus != NotAttempted && !HeidenhainStatus.IsStable(_dncStatus);
+ if (s == HeidenhainStatus.BadNotSupported || (s == HeidenhainStatus.BadUserAccessDenied && !provisionalDncDenial))
+ _suppressed[suppressKey] = s;
+ if (!isCustom) _capabilities[feature] = HeidenhainStatus.State(s);
+ return new Outcome(null, s);
+ }
+ catch (QualityException e)
+ {
+ if (!isCustom) _capabilities[feature] = e.State;
+ return new Outcome(null, e.Status);
+ }
+ catch (Lsv2DecodeException)
+ {
+ if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.InvalidData;
+ return new Outcome(null, HeidenhainStatus.BadDecodingError);
+ }
+ catch (HeidenhainTypeMismatchException)
+ {
+ return new Outcome(null, HeidenhainStatus.BadTypeMismatch);
+ }
+ catch (HeidenhainRangeException)
+ {
+ if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.InvalidData;
+ return new Outcome(null, HeidenhainStatus.BadOutOfRange);
+ }
+ catch (Lsv2TransportException)
+ {
+ _lost = true;
+ _client.Close();
+ return new Outcome(null, HeidenhainStatus.BadNoCommunication);
+ }
+ catch (Exception ex) when (ex is Lsv2PolicyException or ArgumentException)
+ {
+ // A request the read-only policy refuses is never sent and never ends the session: this tag only.
+ if (!isCustom) _capabilities[feature] = HeidenhainCatalog.States.ConfigurationError;
+ return new Outcome(null, HeidenhainStatus.BadConfigurationError);
+ }
+ }
+
+ private async Task TimerAsync(string feature, CancellationToken ct)
+ {
+ var index = -1;
+ for (var i = 0; i < HeidenhainCatalog.TimerNames.Count; i++)
+ if (HeidenhainCatalog.TimerFeature(HeidenhainCatalog.TimerNames[i]) == feature) index = i;
+ if (index < 0) throw new QualityException(HeidenhainStatus.BadNotSupported, HeidenhainCatalog.States.Unsupported);
+ return Lsv2Data.U32(await _client.ReadRuntimeAsync(30 + index, ct).ConfigureAwait(false));
+ }
+
+ private async Task